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 使用各自的认证语义,并支持
|
||||
CIDR、可信代理、严格请求解析和敏感信息最小化;Admin 的读写权限与
|
||||
Distribution 提取权限可按命中凭据分别收敛。
|
||||
Distribution 提取权限可按命中凭据分别收敛。Distribution 凭据还可限制单次
|
||||
提取数量、可访问 Upstream 与地区;未配置时保持既有全范围行为。
|
||||
|
||||
## 架构概览
|
||||
|
||||
|
||||
@ -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: []
|
||||
|
||||
@ -202,6 +202,11 @@ TTL 到期或 Redis 数据丢失后不再保证旧 Key 去重,系统不回退
|
||||
返回 `403`,不会读取请求体、消耗幂等键或调用提取服务。未声明权限的旧凭据保持全
|
||||
权限兼容。
|
||||
|
||||
Distribution 认证凭据还可声明 client 提取约束:maxExtractCount、
|
||||
allowedUpstreams 与 allowedRegions。该约束随实际命中的凭据返回,早于幂等键和
|
||||
提取服务执行。未传地区或 Upstream 过滤时,服务端自动写入凭据允许集合;传入
|
||||
集合外的值、或数量超过凭据上限时返回 403,并且不会消耗库存。
|
||||
|
||||
## 10. 短期运行记录与数据最小化
|
||||
|
||||
Redis 幂等结果只在配置的 TTL 窗口内保留重放响应所需的数据:
|
||||
|
||||
@ -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 是独立认证边界。改变其中一套
|
||||
不得连带改变其他入口。
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
运行同一公用契约。
|
||||
|
||||
@ -3,6 +3,8 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"proxy-pool/internal/domain/clientpolicy"
|
||||
)
|
||||
|
||||
// MaximumCheckURLs bounds per-upstream EGRESS references. The bound keeps
|
||||
@ -78,28 +80,30 @@ type Access struct {
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
Mode string `yaml:"mode"`
|
||||
Permissions []string `yaml:"permissions"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
PasswordFile string `yaml:"passwordFile"`
|
||||
Token string `yaml:"token"`
|
||||
TokenFile string `yaml:"tokenFile"`
|
||||
Header string `yaml:"header"`
|
||||
CIDRs []string `yaml:"cidrs"`
|
||||
Methods []AuthMethod `yaml:"methods"`
|
||||
Mode string `yaml:"mode"`
|
||||
Permissions []string `yaml:"permissions"`
|
||||
ClientPolicy clientpolicy.Policy `yaml:"client"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
PasswordFile string `yaml:"passwordFile"`
|
||||
Token string `yaml:"token"`
|
||||
TokenFile string `yaml:"tokenFile"`
|
||||
Header string `yaml:"header"`
|
||||
CIDRs []string `yaml:"cidrs"`
|
||||
Methods []AuthMethod `yaml:"methods"`
|
||||
}
|
||||
|
||||
type AuthMethod struct {
|
||||
Mode string `yaml:"mode"`
|
||||
Permissions []string `yaml:"permissions"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
PasswordFile string `yaml:"passwordFile"`
|
||||
Header string `yaml:"header"`
|
||||
Value string `yaml:"value"`
|
||||
ValueFile string `yaml:"valueFile"`
|
||||
CIDRs []string `yaml:"cidrs"`
|
||||
Mode string `yaml:"mode"`
|
||||
Permissions []string `yaml:"permissions"`
|
||||
ClientPolicy clientpolicy.Policy `yaml:"client"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
PasswordFile string `yaml:"passwordFile"`
|
||||
Header string `yaml:"header"`
|
||||
Value string `yaml:"value"`
|
||||
ValueFile string `yaml:"valueFile"`
|
||||
CIDRs []string `yaml:"cidrs"`
|
||||
}
|
||||
|
||||
type Limits struct {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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{}
|
||||
|
||||
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"
|
||||
|
||||
"proxy-pool/internal/domain/authorization"
|
||||
"proxy-pool/internal/domain/clientpolicy"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -24,8 +25,9 @@ type authenticator interface {
|
||||
}
|
||||
|
||||
type authenticationResult struct {
|
||||
Principal string
|
||||
Permissions []string
|
||||
Principal string
|
||||
Permissions []string
|
||||
ClientPolicy clientpolicy.Policy
|
||||
}
|
||||
|
||||
type noAuthenticator struct{}
|
||||
@ -36,8 +38,9 @@ func (noAuthenticator) authenticate(*http.Request, string) (authenticationResult
|
||||
func (noAuthenticator) challenges() []string { return nil }
|
||||
|
||||
type scopedAuthenticator struct {
|
||||
delegate authenticator
|
||||
permissions []string
|
||||
delegate authenticator
|
||||
permissions []string
|
||||
clientPolicy clientpolicy.Policy
|
||||
}
|
||||
|
||||
func (auth scopedAuthenticator) authenticate(request *http.Request, source string) (authenticationResult, error) {
|
||||
@ -46,6 +49,7 @@ func (auth scopedAuthenticator) authenticate(request *http.Request, source strin
|
||||
return authenticationResult{}, err
|
||||
}
|
||||
result.Permissions = append([]string(nil), auth.permissions...)
|
||||
result.ClientPolicy = auth.clientPolicy.Clone()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@ -138,13 +142,16 @@ func buildAuthenticator(authentication Authentication, semantics Semantics) (aut
|
||||
if err := authorization.Validate(authentication.Permissions); err != nil {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
if err := authentication.ClientPolicy.Validate(); err != nil {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
header := "Authorization"
|
||||
if semantics == ProxySemantics {
|
||||
header = "Proxy-Authorization"
|
||||
}
|
||||
switch authentication.Mode {
|
||||
case "", ModeNone:
|
||||
if len(authentication.Permissions) != 0 {
|
||||
if len(authentication.Permissions) != 0 || !authentication.ClientPolicy.IsZero() {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
return noAuthenticator{}, nil
|
||||
@ -152,25 +159,25 @@ func buildAuthenticator(authentication Authentication, semantics Semantics) (aut
|
||||
if authentication.Username == "" || authentication.Password == "" {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
return scopedAuthenticator{delegate: basicAuthenticator{header: header, username: authentication.Username, password: authentication.Password}, permissions: authentication.Permissions}, nil
|
||||
return scopedAuthenticator{delegate: basicAuthenticator{header: header, username: authentication.Username, password: authentication.Password}, permissions: authentication.Permissions, clientPolicy: authentication.ClientPolicy.Clone()}, nil
|
||||
case ModeAPIKey:
|
||||
if !validHeaderName(authentication.Header) || authentication.Token == "" {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
return scopedAuthenticator{delegate: tokenAuthenticator{mode: ModeAPIKey, header: authentication.Header, token: authentication.Token}, permissions: authentication.Permissions}, nil
|
||||
return scopedAuthenticator{delegate: tokenAuthenticator{mode: ModeAPIKey, header: authentication.Header, token: authentication.Token}, permissions: authentication.Permissions, clientPolicy: authentication.ClientPolicy.Clone()}, nil
|
||||
case ModeBearer:
|
||||
if authentication.Token == "" {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
return scopedAuthenticator{delegate: tokenAuthenticator{mode: ModeBearer, header: header, token: authentication.Token}, permissions: authentication.Permissions}, nil
|
||||
return scopedAuthenticator{delegate: tokenAuthenticator{mode: ModeBearer, header: header, token: authentication.Token}, permissions: authentication.Permissions, clientPolicy: authentication.ClientPolicy.Clone()}, nil
|
||||
case ModeIPWhitelist:
|
||||
allowed, err := newCIDRMatcher(authentication.CIDRs)
|
||||
if err != nil || len(authentication.CIDRs) == 0 {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
return scopedAuthenticator{delegate: ipAuthenticator{allowed: allowed}, permissions: authentication.Permissions}, nil
|
||||
return scopedAuthenticator{delegate: ipAuthenticator{allowed: allowed}, permissions: authentication.Permissions, clientPolicy: authentication.ClientPolicy.Clone()}, nil
|
||||
case ModeAny:
|
||||
if len(authentication.Permissions) != 0 {
|
||||
if len(authentication.Permissions) != 0 || !authentication.ClientPolicy.IsZero() {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
if len(authentication.Methods) == 0 {
|
||||
@ -195,8 +202,8 @@ func buildMethod(method Method, semantics Semantics) (authenticator, error) {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
authentication := Authentication{
|
||||
Mode: method.Mode, Permissions: method.Permissions, Username: method.Username,
|
||||
Password: method.Password, Header: method.Header, Token: method.Value, CIDRs: method.CIDRs,
|
||||
Mode: method.Mode, Permissions: method.Permissions, ClientPolicy: method.ClientPolicy,
|
||||
Username: method.Username, Password: method.Password, Header: method.Header, Token: method.Value, CIDRs: method.CIDRs,
|
||||
}
|
||||
if method.Mode == ModeAny || method.Mode == ModeNone || method.Mode == "" {
|
||||
return nil, ErrInvalidConfig
|
||||
|
||||
@ -28,27 +28,29 @@ func NewFromListener(listener config.Listener, clientIdentification string, sema
|
||||
methods := make([]Method, 0, len(listener.Auth.Methods))
|
||||
for _, method := range listener.Auth.Methods {
|
||||
methods = append(methods, Method{
|
||||
Mode: method.Mode,
|
||||
Permissions: append([]string(nil), method.Permissions...),
|
||||
Username: method.Username,
|
||||
Password: method.Password,
|
||||
Header: method.Header,
|
||||
Value: method.Value,
|
||||
CIDRs: append([]string(nil), method.CIDRs...),
|
||||
Mode: method.Mode,
|
||||
Permissions: append([]string(nil), method.Permissions...),
|
||||
ClientPolicy: method.ClientPolicy.Clone(),
|
||||
Username: method.Username,
|
||||
Password: method.Password,
|
||||
Header: method.Header,
|
||||
Value: method.Value,
|
||||
CIDRs: append([]string(nil), method.CIDRs...),
|
||||
})
|
||||
}
|
||||
return New(Config{
|
||||
TrustedProxies: append([]string(nil), listener.Access.TrustedProxies...),
|
||||
AllowCIDRs: append([]string(nil), listener.Access.AllowCIDRs...),
|
||||
Authentication: Authentication{
|
||||
Mode: listener.Auth.Mode,
|
||||
Permissions: append([]string(nil), listener.Auth.Permissions...),
|
||||
Username: listener.Auth.Username,
|
||||
Password: listener.Auth.Password,
|
||||
Header: listener.Auth.Header,
|
||||
Token: listener.Auth.Token,
|
||||
CIDRs: append([]string(nil), listener.Auth.CIDRs...),
|
||||
Methods: methods,
|
||||
Mode: listener.Auth.Mode,
|
||||
Permissions: append([]string(nil), listener.Auth.Permissions...),
|
||||
ClientPolicy: listener.Auth.ClientPolicy.Clone(),
|
||||
Username: listener.Auth.Username,
|
||||
Password: listener.Auth.Password,
|
||||
Header: listener.Auth.Header,
|
||||
Token: listener.Auth.Token,
|
||||
CIDRs: append([]string(nil), listener.Auth.CIDRs...),
|
||||
Methods: methods,
|
||||
},
|
||||
ClientIdentification: clientIdentification,
|
||||
Semantics: semantics,
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"proxy-pool/internal/domain/authorization"
|
||||
"proxy-pool/internal/domain/clientpolicy"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -31,24 +32,26 @@ var ErrInvalidConfig = errors.New("invalid HTTP security configuration")
|
||||
type Semantics uint8
|
||||
|
||||
type Authentication struct {
|
||||
Mode string
|
||||
Permissions []string
|
||||
Username string
|
||||
Password string
|
||||
Header string
|
||||
Token string
|
||||
CIDRs []string
|
||||
Methods []Method
|
||||
Mode string
|
||||
Permissions []string
|
||||
ClientPolicy clientpolicy.Policy
|
||||
Username string
|
||||
Password string
|
||||
Header string
|
||||
Token string
|
||||
CIDRs []string
|
||||
Methods []Method
|
||||
}
|
||||
|
||||
type Method struct {
|
||||
Mode string
|
||||
Permissions []string
|
||||
Username string
|
||||
Password string
|
||||
Header string
|
||||
Value string
|
||||
CIDRs []string
|
||||
Mode string
|
||||
Permissions []string
|
||||
ClientPolicy clientpolicy.Policy
|
||||
Username string
|
||||
Password string
|
||||
Header string
|
||||
Value string
|
||||
CIDRs []string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@ -60,9 +63,10 @@ type Config struct {
|
||||
}
|
||||
|
||||
type Identity struct {
|
||||
ClientID string
|
||||
SourceIP string
|
||||
Permissions []string
|
||||
ClientID string
|
||||
SourceIP string
|
||||
Permissions []string
|
||||
ClientPolicy clientpolicy.Policy
|
||||
}
|
||||
|
||||
// Allows reports whether this identity has the endpoint's fixed permission.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user