feat: enforce credential endpoint permissions
This commit is contained in:
parent
ec3ceb2c9a
commit
aaed288211
@ -78,7 +78,8 @@ Proxy Pool 用 Controller 协调这些变化,并让 Gateway 数据面只消费
|
|||||||
Upstream 启停、Routing 切换和配置发布会向本进程全部在线 Worker 快照流广播刷新;
|
Upstream 启停、Routing 切换和配置发布会向本进程全部在线 Worker 快照流广播刷新;
|
||||||
定时刷新仍作为跨进程收敛与失效保护。
|
定时刷新仍作为跨进程收敛与失效保护。
|
||||||
- **安全边界**:Gateway、Distribution 与 Admin 使用各自的认证语义,并支持
|
- **安全边界**:Gateway、Distribution 与 Admin 使用各自的认证语义,并支持
|
||||||
CIDR、可信代理、严格请求解析和敏感信息最小化。
|
CIDR、可信代理、严格请求解析和敏感信息最小化;Admin 的读写权限与
|
||||||
|
Distribution 提取权限可按命中凭据分别收敛。
|
||||||
|
|
||||||
## 架构概览
|
## 架构概览
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,10 @@ openapi: 3.1.0
|
|||||||
info:
|
info:
|
||||||
title: Proxy Pool Admin API
|
title: Proxy Pool Admin API
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: 运维状态与受控变更接口。该入口必须与 Distribution 分端口和权限。
|
description: |
|
||||||
|
运维状态与受控变更接口。该入口必须与 Distribution 分端口和权限。
|
||||||
|
已认证凭据可配置 `admin:read`(Status、审计)和 `admin:write`(变更);
|
||||||
|
未配置权限的旧凭据保留全权限行为。
|
||||||
servers:
|
servers:
|
||||||
- url: http://127.0.0.1:8082
|
- url: http://127.0.0.1:8082
|
||||||
tags:
|
tags:
|
||||||
@ -22,6 +25,7 @@ paths:
|
|||||||
tags: [Status]
|
tags: [Status]
|
||||||
operationId: getStatus
|
operationId: getStatus
|
||||||
summary: 获取控制面摘要状态
|
summary: 获取控制面摘要状态
|
||||||
|
description: 需要 `admin:read`。
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: 不含 Proxy 地址、Client 标识或 Secret 的聚合状态
|
description: 不含 Proxy 地址、Client 标识或 Secret 的聚合状态
|
||||||
@ -43,6 +47,8 @@ paths:
|
|||||||
operationId: listAuditRecords
|
operationId: listAuditRecords
|
||||||
summary: 按 ID 游标读取管理面审计记录
|
summary: 按 ID 游标读取管理面审计记录
|
||||||
description: |
|
description: |
|
||||||
|
需要 `admin:read`。
|
||||||
|
|
||||||
仅返回权威管理面的变更审计记录,按 `id` 升序排列。`afterId` 是排他游标:
|
仅返回权威管理面的变更审计记录,按 `id` 升序排列。`afterId` 是排他游标:
|
||||||
后续页面只包含 `id` 大于该值的记录。未传 `limit` 时返回 100 条,单页最多
|
后续页面只包含 `id` 大于该值的记录。未传 `limit` 时返回 100 条,单页最多
|
||||||
1000 条。
|
1000 条。
|
||||||
@ -78,6 +84,7 @@ paths:
|
|||||||
tags: [Upstreams]
|
tags: [Upstreams]
|
||||||
operationId: enableUpstream
|
operationId: enableUpstream
|
||||||
summary: 启用 Upstream
|
summary: 启用 Upstream
|
||||||
|
description: 需要 `admin:write`。
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/UpstreamName'
|
- $ref: '#/components/parameters/UpstreamName'
|
||||||
- $ref: '#/components/parameters/RequestID'
|
- $ref: '#/components/parameters/RequestID'
|
||||||
@ -96,6 +103,7 @@ paths:
|
|||||||
tags: [Upstreams]
|
tags: [Upstreams]
|
||||||
operationId: disableUpstream
|
operationId: disableUpstream
|
||||||
summary: 禁用 Upstream 并使已有资源自然 Drain
|
summary: 禁用 Upstream 并使已有资源自然 Drain
|
||||||
|
description: 需要 `admin:write`。
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/UpstreamName'
|
- $ref: '#/components/parameters/UpstreamName'
|
||||||
- $ref: '#/components/parameters/RequestID'
|
- $ref: '#/components/parameters/RequestID'
|
||||||
@ -114,6 +122,7 @@ paths:
|
|||||||
tags: [Routing]
|
tags: [Routing]
|
||||||
operationId: switchRouting
|
operationId: switchRouting
|
||||||
summary: 原子切换 Sequential Routing 当前 Upstream
|
summary: 原子切换 Sequential Routing 当前 Upstream
|
||||||
|
description: 需要 `admin:write`。
|
||||||
parameters:
|
parameters:
|
||||||
- name: name
|
- name: name
|
||||||
in: path
|
in: path
|
||||||
@ -150,6 +159,7 @@ paths:
|
|||||||
tags: [Configuration]
|
tags: [Configuration]
|
||||||
operationId: reloadConfiguration
|
operationId: reloadConfiguration
|
||||||
summary: 严格校验并原子发布新配置快照
|
summary: 严格校验并原子发布新配置快照
|
||||||
|
description: 需要 `admin:write`。
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/RequestID'
|
- $ref: '#/components/parameters/RequestID'
|
||||||
responses:
|
responses:
|
||||||
|
|||||||
@ -7,7 +7,9 @@ info:
|
|||||||
Redis operation atomically removes or marks every returned proxy as
|
Redis operation atomically removes or marks every returned proxy as
|
||||||
extracted in the TTL activity pool. Proxy details and per-extraction
|
extracted in the TTL activity pool. Proxy details and per-extraction
|
||||||
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.
|
lease API. An authenticated credential may be limited to the fixed
|
||||||
|
`distribution:extract` permission; legacy credentials without configured
|
||||||
|
permissions retain full access.
|
||||||
servers:
|
servers:
|
||||||
- url: http://127.0.0.1:8081
|
- url: http://127.0.0.1:8081
|
||||||
description: Distribution API
|
description: Distribution API
|
||||||
@ -21,6 +23,8 @@ paths:
|
|||||||
operationId: extractProxies
|
operationId: extractProxies
|
||||||
summary: 一次性独占提取代理
|
summary: 一次性独占提取代理
|
||||||
description: |
|
description: |
|
||||||
|
需要 `distribution:extract`。
|
||||||
|
|
||||||
服务端通过单个 Redis 原子操作完成筛选、TTL/所有权复核、Gateway 预留、
|
服务端通过单个 Redis 原子操作完成筛选、TTL/所有权复核、Gateway 预留、
|
||||||
从可分配池移除所选条目和短期幂等结果写入,再返回代理。相同代理不会
|
从可分配池移除所选条目和短期幂等结果写入,再返回代理。相同代理不会
|
||||||
返回给两个成功请求,提取路径不访问 PostgreSQL。
|
返回给两个成功请求,提取路径不访问 PostgreSQL。
|
||||||
|
|||||||
@ -15,6 +15,14 @@ Admin API 使用独立监听器与权限,契约位于 `api/openapi/admin.yaml`
|
|||||||
所有写操作写审计记录并返回最终 Request ID 与版本。Enable/Disable 对目标状态
|
所有写操作写审计记录并返回最终 Request ID 与版本。Enable/Disable 对目标状态
|
||||||
幂等;Routing Switch 必须携带 `expectedCurrent`,避免并发操作跳过多个供应商。
|
幂等;Routing Switch 必须携带 `expectedCurrent`,避免并发操作跳过多个供应商。
|
||||||
|
|
||||||
|
## 授权
|
||||||
|
|
||||||
|
认证成功后的权限由 Listener `auth.permissions` 或 `auth.methods[].permissions` 提供。
|
||||||
|
`admin:read` 允许 Status 与审计查询;`admin:write` 允许 Upstream 启停、Routing
|
||||||
|
切换和配置重载。每个权限独立匹配,写权限不隐含读权限;未配置权限的旧凭据保持
|
||||||
|
全权限兼容。`mode: any` 使用实际命中凭据的方法级权限。权限不足返回 `403`,不会
|
||||||
|
调用 Service、写审计或触发配置发布。
|
||||||
|
|
||||||
配置重载校验失败返回 422,旧配置继续运行。Status 只返回低基数聚合信息,
|
配置重载校验失败返回 422,旧配置继续运行。Status 只返回低基数聚合信息,
|
||||||
不得返回 Proxy 地址、凭据、Client 标识或完整 Provider URL。
|
不得返回 Proxy 地址、凭据、Client 标识或完整 Provider URL。
|
||||||
|
|
||||||
|
|||||||
@ -197,6 +197,11 @@ TTL 到期或 Redis 数据丢失后不再保证旧 Key 去重,系统不回退
|
|||||||
|
|
||||||
错误响应不得包含 Provider Secret、Proxy 凭据、SQL 或内部拓扑。
|
错误响应不得包含 Provider Secret、Proxy 凭据、SQL 或内部拓扑。
|
||||||
|
|
||||||
|
`POST /api/v1/proxies/extract` 需要 `distribution:extract`。权限从 Listener 的
|
||||||
|
`auth.permissions` 或 `mode: any` 的命中 `auth.methods[].permissions` 取得;缺少权限
|
||||||
|
返回 `403`,不会读取请求体、消耗幂等键或调用提取服务。未声明权限的旧凭据保持全
|
||||||
|
权限兼容。
|
||||||
|
|
||||||
## 10. 短期运行记录与数据最小化
|
## 10. 短期运行记录与数据最小化
|
||||||
|
|
||||||
Redis 幂等结果只在配置的 TTL 窗口内保留重放响应所需的数据:
|
Redis 幂等结果只在配置的 TTL 窗口内保留重放响应所需的数据:
|
||||||
|
|||||||
@ -122,6 +122,34 @@ Lua 可精确表示的整数范围内。
|
|||||||
- `any`:`methods` 中任一方法成功即可;Bearer 方法的 Secret 使用
|
- `any`:`methods` 中任一方法成功即可;Bearer 方法的 Secret 使用
|
||||||
`value`/`valueFile`。
|
`value`/`valueFile`。
|
||||||
|
|
||||||
|
### 3.2 端点权限
|
||||||
|
|
||||||
|
`auth.permissions` 是普通认证方式(Basic、API Key、Bearer、IP 白名单)的固定权限
|
||||||
|
集合。未配置时保留历史全权限行为;显式配置后只允许下列有限值:
|
||||||
|
|
||||||
|
- `admin:read`:读取 Admin Status 与审计页。
|
||||||
|
- `admin:write`:执行 Upstream/Routing/配置变更。
|
||||||
|
- `distribution:extract`:调用 Distribution 独占提取。
|
||||||
|
- `*`:所有权限,且必须单独出现。
|
||||||
|
|
||||||
|
`mode: any` 必须将 `permissions` 写在各 `methods` 项中,权限随实际命中的凭据
|
||||||
|
返回,顶层 `auth.permissions` 不允许同时配置。权限值不接受空白、重复或未知项。
|
||||||
|
缺少所需权限的已认证请求返回 `403`,并在进入 JSON 解码、提取或管理 mutation 前
|
||||||
|
结束。`mode: none` 不允许配置权限,避免把匿名访问误配置为受控授权。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
admin:
|
||||||
|
auth:
|
||||||
|
mode: any
|
||||||
|
methods:
|
||||||
|
- mode: bearer
|
||||||
|
valueFile: /run/secrets/admin-reader-token
|
||||||
|
permissions: [admin:read]
|
||||||
|
- mode: bearer
|
||||||
|
valueFile: /run/secrets/admin-writer-token
|
||||||
|
permissions: [admin:read, admin:write]
|
||||||
|
```
|
||||||
|
|
||||||
Gateway、Distribution、Admin 与 Provider API 是独立认证边界。改变其中一套
|
Gateway、Distribution、Admin 与 Provider API 是独立认证边界。改变其中一套
|
||||||
不得连带改变其他入口。
|
不得连带改变其他入口。
|
||||||
|
|
||||||
|
|||||||
@ -190,8 +190,8 @@ reject/wait/direct,并会在快照刷新后看到停用状态。
|
|||||||
- [x] Implement the Worker heartbeat receiving path and session lifecycle.
|
- [x] Implement the Worker heartbeat receiving path and session lifecycle.
|
||||||
- [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/enable/disable/switch/reload
|
- [x] Expose Distribution extraction/status and Admin status/audit/enable/disable/switch/reload
|
||||||
HTTP handlers and contracts.
|
HTTP handlers and contracts with credential-level endpoint permissions.
|
||||||
- [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.
|
||||||
|
|
||||||
|
|||||||
@ -46,8 +46,9 @@
|
|||||||
引用 Store 与 Reconciler Adapter 已实现。
|
引用 Store 与 Reconciler Adapter 已实现。
|
||||||
- `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 已将二者装配到独立监听器并支持联动优雅停机。
|
固定权限与本地准入保护链已接入,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
|
||||||
运行同一公用契约。
|
运行同一公用契约。
|
||||||
@ -125,9 +126,8 @@ Controller/Gateway 入口,完整 mTLS 运行时拓扑仍只有静态验证。
|
|||||||
7. BASIC Checker 调度与 HTTP/HTTPS/SOCKS5 探测器、全局与 TARGET Profile 的 Memory/Redis
|
7. BASIC Checker 调度与 HTTP/HTTPS/SOCKS5 探测器、全局与 TARGET Profile 的 Memory/Redis
|
||||||
原子归并、Controller Reducer 和 Observation 上报 RPC 已完成;EGRESS、TARGET
|
原子归并、Controller Reducer 和 Observation 上报 RPC 已完成;EGRESS、TARGET
|
||||||
生产任务调度与 REMOVE 编排仍待实现。
|
生产任务调度与 REMOVE 编排仍待实现。
|
||||||
8. Admin/Distribution 细粒度授权;Distribution 分布式限流和 Admin 审计查询已完成。
|
8. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
|
||||||
9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
|
9. 将 reject/wait/direct 接入 Distribution 运行链。
|
||||||
10. 将 reject/wait/direct 接入 Distribution 运行链。
|
|
||||||
## 4. 容量结论
|
## 4. 容量结论
|
||||||
|
|
||||||
100,000 QPS 是集群设计输入,不是本次验证结果。只有实现上述运行时,并在
|
100,000 QPS 是集群设计输入,不是本次验证结果。只有实现上述运行时,并在
|
||||||
|
|||||||
@ -17,6 +17,9 @@
|
|||||||
- `httpsecurity.Protection` 统一实现 Basic、API Key、Bearer、CIDR、可信代理链、
|
- `httpsecurity.Protection` 统一实现 Basic、API Key、Bearer、CIDR、可信代理链、
|
||||||
Client ID 与入口准入;API 使用 401/`WWW-Authenticate`,Gateway 使用
|
Client ID 与入口准入;API 使用 401/`WWW-Authenticate`,Gateway 使用
|
||||||
407/`Proxy-Authenticate`。
|
407/`Proxy-Authenticate`。
|
||||||
|
- Listener 可将固定权限绑定到单个认证方式,或在 `mode: any` 中绑定到每个凭据;
|
||||||
|
`admin:read`、`admin:write` 与 `distribution:extract` 在 Handler 入口完成匹配,
|
||||||
|
不把无权请求传入管理 mutation 或 Proxy 提取流程。
|
||||||
- Token Client ID 使用 SHA-256 的 128 位摘要前缀,不把 Token 本身写入领域、
|
- Token Client ID 使用 SHA-256 的 128 位摘要前缀,不把 Token 本身写入领域、
|
||||||
日志或审计键。
|
日志或审计键。
|
||||||
|
|
||||||
|
|||||||
@ -79,6 +79,7 @@ type Access struct {
|
|||||||
|
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
Mode string `yaml:"mode"`
|
Mode string `yaml:"mode"`
|
||||||
|
Permissions []string `yaml:"permissions"`
|
||||||
Username string `yaml:"username"`
|
Username string `yaml:"username"`
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
PasswordFile string `yaml:"passwordFile"`
|
PasswordFile string `yaml:"passwordFile"`
|
||||||
@ -91,6 +92,7 @@ type Auth struct {
|
|||||||
|
|
||||||
type AuthMethod struct {
|
type AuthMethod struct {
|
||||||
Mode string `yaml:"mode"`
|
Mode string `yaml:"mode"`
|
||||||
|
Permissions []string `yaml:"permissions"`
|
||||||
Username string `yaml:"username"`
|
Username string `yaml:"username"`
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
PasswordFile string `yaml:"passwordFile"`
|
PasswordFile string `yaml:"passwordFile"`
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
|
|
||||||
|
"proxy-pool/internal/domain/authorization"
|
||||||
)
|
)
|
||||||
|
|
||||||
const validConfig = `
|
const validConfig = `
|
||||||
@ -327,6 +329,42 @@ func TestValidateAcceptsBearerListenerAuthentication(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateListenerPermissionConfiguration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := mustLoadValidConfig(t)
|
||||||
|
cfg.Admin.Enabled = true
|
||||||
|
cfg.Admin.Listen = "127.0.0.1:8082"
|
||||||
|
cfg.Admin.Auth = Auth{
|
||||||
|
Mode: "bearer",
|
||||||
|
Token: "admin-token",
|
||||||
|
Permissions: []string{authorization.AdminRead},
|
||||||
|
}
|
||||||
|
if err := Validate(cfg); err != nil {
|
||||||
|
t.Fatalf("Validate(admin read permission) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Admin.Auth.Permissions = []string{"unknown"}
|
||||||
|
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "permissions") {
|
||||||
|
t.Fatalf("Validate(unknown permission) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Admin.Auth = Auth{
|
||||||
|
Mode: "any",
|
||||||
|
Permissions: []string{authorization.AdminRead},
|
||||||
|
Methods: []AuthMethod{{
|
||||||
|
Mode: "bearer", Value: "read-token", Permissions: []string{authorization.AdminRead},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "auth.permissions") {
|
||||||
|
t.Fatalf("Validate(any top-level permission) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Admin.Auth.Permissions = nil
|
||||||
|
if err := Validate(cfg); err != nil {
|
||||||
|
t.Fatalf("Validate(any method permission) error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateMetricsListener(t *testing.T) {
|
func TestValidateMetricsListener(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
cfg := mustLoadValidConfig(t)
|
cfg := mustLoadValidConfig(t)
|
||||||
|
|||||||
@ -80,9 +80,11 @@ func cloneListener(source Listener) Listener {
|
|||||||
cloned.Access.AllowCIDRs = cloneStrings(source.Access.AllowCIDRs)
|
cloned.Access.AllowCIDRs = cloneStrings(source.Access.AllowCIDRs)
|
||||||
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.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.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)
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"proxy-pool/internal/domain/authorization"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -372,8 +374,14 @@ func validateStrategy(scope string, upstreams []string, strategy Strategy) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateListenerAuth(listener string, auth Auth) error {
|
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)
|
||||||
|
}
|
||||||
switch auth.Mode {
|
switch auth.Mode {
|
||||||
case "none":
|
case "none":
|
||||||
|
if len(auth.Permissions) != 0 {
|
||||||
|
return fmt.Errorf("validate %s auth.permissions: 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 == "") {
|
||||||
@ -395,6 +403,9 @@ func validateListenerAuth(listener string, auth Auth) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case "any":
|
case "any":
|
||||||
|
if len(auth.Permissions) != 0 {
|
||||||
|
return fmt.Errorf("validate %s auth.permissions: configure permissions 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)
|
||||||
}
|
}
|
||||||
@ -410,6 +421,9 @@ func validateListenerAuth(listener string, auth Auth) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateAuthMethod(listener string, index int, method AuthMethod) error {
|
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)
|
||||||
|
}
|
||||||
switch method.Mode {
|
switch method.Mode {
|
||||||
case "usernamePassword":
|
case "usernamePassword":
|
||||||
if method.Username == "" || (method.Password == "" && method.PasswordFile == "") {
|
if method.Username == "" || (method.Password == "" && method.PasswordFile == "") {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"proxy-pool/internal/domain/adminstate"
|
"proxy-pool/internal/domain/adminstate"
|
||||||
|
"proxy-pool/internal/domain/authorization"
|
||||||
"proxy-pool/internal/platform/httpapi"
|
"proxy-pool/internal/platform/httpapi"
|
||||||
"proxy-pool/internal/platform/httpsecurity"
|
"proxy-pool/internal/platform/httpsecurity"
|
||||||
)
|
)
|
||||||
@ -170,18 +171,27 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
|
|||||||
if !requireMethod(writer, request, http.MethodGet, requestID) {
|
if !requireMethod(writer, request, http.MethodGet, requestID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requirePermission(writer, identity, authorization.AdminRead, requestID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
handler.getStatus(writer, request, requestID)
|
handler.getStatus(writer, request, requestID)
|
||||||
return
|
return
|
||||||
case auditPath:
|
case auditPath:
|
||||||
if !requireMethod(writer, request, http.MethodGet, requestID) {
|
if !requireMethod(writer, request, http.MethodGet, requestID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requirePermission(writer, identity, authorization.AdminRead, requestID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
handler.readAudit(writer, request, requestID)
|
handler.readAudit(writer, request, requestID)
|
||||||
return
|
return
|
||||||
case reloadPath:
|
case reloadPath:
|
||||||
if !requireMethod(writer, request, http.MethodPost, requestID) {
|
if !requireMethod(writer, request, http.MethodPost, requestID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requirePermission(writer, identity, authorization.AdminWrite, requestID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
handler.reload(writer, request, requestID, identity)
|
handler.reload(writer, request, requestID, identity)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -190,6 +200,9 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
|
|||||||
if !requireMethod(writer, request, http.MethodPost, requestID) {
|
if !requireMethod(writer, request, http.MethodPost, requestID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requirePermission(writer, identity, authorization.AdminWrite, requestID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
handler.setUpstreamEnabled(writer, request, name, action == "enable", requestID, identity)
|
handler.setUpstreamEnabled(writer, request, name, action == "enable", requestID, identity)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -197,6 +210,9 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
|
|||||||
if !requireMethod(writer, request, http.MethodPost, requestID) {
|
if !requireMethod(writer, request, http.MethodPost, requestID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requirePermission(writer, identity, authorization.AdminWrite, requestID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
handler.switchRouting(writer, request, name, requestID, identity)
|
handler.switchRouting(writer, request, name, requestID, identity)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -204,6 +220,19 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
|
|||||||
writeTransportProblem(writer, http.StatusNotFound, "NOT_FOUND", "Not found", "the requested endpoint does not exist", requestID)
|
writeTransportProblem(writer, http.StatusNotFound, "NOT_FOUND", "Not found", "the requested endpoint does not exist", requestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requirePermission(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
identity httpsecurity.Identity,
|
||||||
|
required string,
|
||||||
|
requestID string,
|
||||||
|
) bool {
|
||||||
|
if identity.Allows(required) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeTransportProblem(writer, http.StatusForbidden, "FORBIDDEN", "Forbidden", "the authenticated identity lacks endpoint permission", requestID)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (handler *Handler) getStatus(writer http.ResponseWriter, request *http.Request, requestID string) {
|
func (handler *Handler) getStatus(writer http.ResponseWriter, request *http.Request, requestID string) {
|
||||||
status, err := handler.service.Status(request.Context())
|
status, err := handler.service.Status(request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"proxy-pool/internal/domain/authorization"
|
||||||
"proxy-pool/internal/platform/httpapi"
|
"proxy-pool/internal/platform/httpapi"
|
||||||
"proxy-pool/internal/platform/httpsecurity"
|
"proxy-pool/internal/platform/httpsecurity"
|
||||||
)
|
)
|
||||||
@ -39,6 +40,35 @@ func TestHandlerReturnsStatusWithoutSensitiveDetails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlerEnforcesReadAndWritePermissions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
service := &stubService{}
|
||||||
|
readHandler, err := NewHandler(service, scopedAuthorizer{permissions: []string{authorization.AdminRead}}, Options{MaxBodyBytes: 1024})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHandler(read) error = %v", err)
|
||||||
|
}
|
||||||
|
status := httptest.NewRecorder()
|
||||||
|
readHandler.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/api/v1/status", nil))
|
||||||
|
if status.Code != http.StatusOK || service.statusCalls != 1 {
|
||||||
|
t.Fatalf("read status = %d calls=%d", status.Code, service.statusCalls)
|
||||||
|
}
|
||||||
|
mutation := httptest.NewRecorder()
|
||||||
|
readHandler.ServeHTTP(mutation, httptest.NewRequest(http.MethodPost, "/api/v1/config/reload", nil))
|
||||||
|
if mutation.Code != http.StatusForbidden || service.lastReload != (ReloadCommand{}) || !strings.Contains(mutation.Body.String(), `"code":"FORBIDDEN"`) {
|
||||||
|
t.Fatalf("read mutation response = status %d command %+v body=%s", mutation.Code, service.lastReload, mutation.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
writeHandler, err := NewHandler(service, scopedAuthorizer{permissions: []string{authorization.AdminWrite}}, Options{MaxBodyBytes: 1024})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHandler(write) error = %v", err)
|
||||||
|
}
|
||||||
|
audit := httptest.NewRecorder()
|
||||||
|
writeHandler.ServeHTTP(audit, httptest.NewRequest(http.MethodGet, "/api/v1/audit", nil))
|
||||||
|
if audit.Code != http.StatusForbidden || service.auditCalls != 0 || !strings.Contains(audit.Body.String(), `"code":"FORBIDDEN"`) {
|
||||||
|
t.Fatalf("write audit response = status %d calls=%d body=%s", audit.Code, service.auditCalls, audit.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlerReadsBoundedAuditPage(t *testing.T) {
|
func TestHandlerReadsBoundedAuditPage(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
occurredAt := time.Date(2026, 8, 2, 9, 30, 0, 0, time.UTC)
|
occurredAt := time.Date(2026, 8, 2, 9, 30, 0, 0, time.UTC)
|
||||||
@ -396,6 +426,12 @@ func (allowAuthorizer) Resolve(*http.Request) (httpsecurity.Identity, error) {
|
|||||||
return httpsecurity.Identity{ClientID: "admin:test", SourceIP: "192.0.2.10"}, nil
|
return httpsecurity.Identity{ClientID: "admin:test", SourceIP: "192.0.2.10"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type scopedAuthorizer struct{ permissions []string }
|
||||||
|
|
||||||
|
func (authorizer scopedAuthorizer) Resolve(*http.Request) (httpsecurity.Identity, error) {
|
||||||
|
return httpsecurity.Identity{ClientID: "admin:scoped", SourceIP: "192.0.2.20", Permissions: authorizer.permissions}, nil
|
||||||
|
}
|
||||||
|
|
||||||
type rejectAuthorizer struct{}
|
type rejectAuthorizer struct{}
|
||||||
|
|
||||||
func (rejectAuthorizer) Resolve(*http.Request) (httpsecurity.Identity, error) {
|
func (rejectAuthorizer) Resolve(*http.Request) (httpsecurity.Identity, error) {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
controllerExtraction "proxy-pool/internal/controller/extraction"
|
controllerExtraction "proxy-pool/internal/controller/extraction"
|
||||||
|
"proxy-pool/internal/domain/authorization"
|
||||||
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"
|
||||||
@ -173,6 +174,10 @@ func (h *Handler) handleExtract(writer http.ResponseWriter, request *http.Reques
|
|||||||
h.writeProblem(writer, problemBadRequest(requestID, "INVALID_REQUEST", "Invalid request", "", nil))
|
h.writeProblem(writer, problemBadRequest(requestID, "INVALID_REQUEST", "Invalid request", "", nil))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !identity.Allows(authorization.DistributionExtract) {
|
||||||
|
h.writeProblem(writer, httpapi.NewProblem(http.StatusForbidden, "FORBIDDEN", "Forbidden", "", requestID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
idempotencyKey, err := validateIdempotencyKey(request.Header.Values(headerIdempotencyKey))
|
idempotencyKey, err := validateIdempotencyKey(request.Header.Values(headerIdempotencyKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
controllerExtraction "proxy-pool/internal/controller/extraction"
|
controllerExtraction "proxy-pool/internal/controller/extraction"
|
||||||
|
"proxy-pool/internal/domain/authorization"
|
||||||
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"
|
||||||
@ -157,6 +158,28 @@ func TestHandlerExtractSuccessMapsOpenAPIDTOAndReturnsRequestID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlerRejectsIdentityWithoutExtractPermission(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
extractor := &fakeExtractor{}
|
||||||
|
handler := mustNewHandler(t, Config{BodyLimitBytes: 1024}, Dependencies{
|
||||||
|
Extractor: extractor,
|
||||||
|
Identity: fakeIdentityResolver{identity: Identity{
|
||||||
|
ClientID: "tenant-read-only", SourceIP: "198.51.100.8", Permissions: []string{authorization.AdminRead},
|
||||||
|
}},
|
||||||
|
Readiness: fakeReadinessChecker{},
|
||||||
|
})
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/proxies/extract", strings.NewReader(`{"count":1}`))
|
||||||
|
request.Header.Set("Content-Type", httpapi.JSONContentType)
|
||||||
|
request.Header.Set(headerIdempotencyKey, "idem-12345678")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusForbidden || extractor.calls != 0 || !strings.Contains(recorder.Body.String(), `"code":"FORBIDDEN"`) {
|
||||||
|
t.Fatalf("response = status %d calls=%d body=%s", recorder.Code, extractor.calls, recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlerRejectsEmptyResolvedIdentity(t *testing.T) {
|
func TestHandlerRejectsEmptyResolvedIdentity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
extractor := &fakeExtractor{}
|
extractor := &fakeExtractor{}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"proxy-pool/internal/config"
|
"proxy-pool/internal/config"
|
||||||
"proxy-pool/internal/controller/admin"
|
"proxy-pool/internal/controller/admin"
|
||||||
controllerExtraction "proxy-pool/internal/controller/extraction"
|
controllerExtraction "proxy-pool/internal/controller/extraction"
|
||||||
|
"proxy-pool/internal/domain/authorization"
|
||||||
"proxy-pool/internal/platform/httpserver"
|
"proxy-pool/internal/platform/httpserver"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -79,6 +80,45 @@ func TestRuntimeServesDistributionAndAdminOnIndependentListeners(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRuntimeAppliesMatchedCredentialPermissions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := runtimeConfig()
|
||||||
|
cfg.Admin.Auth = config.Auth{Mode: "any", Methods: []config.AuthMethod{
|
||||||
|
{Mode: "bearer", Value: "read-token", Permissions: []string{authorization.AdminRead}},
|
||||||
|
{Mode: "bearer", Value: "write-token", Permissions: []string{authorization.AdminWrite}},
|
||||||
|
}}
|
||||||
|
runtime, err := New(cfg, Dependencies{
|
||||||
|
Extractor: stubExtractor{}, Readiness: stubReadiness{}, AdminService: &stubAdminService{},
|
||||||
|
}, Options{HTTP: testHTTPOptions()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
distributionListener := mustListen(t)
|
||||||
|
adminListener := mustListen(t)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
result := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
result <- runtime.Serve(ctx, Listeners{Distribution: distributionListener, Admin: adminListener})
|
||||||
|
}()
|
||||||
|
|
||||||
|
adminURL := "http://" + adminListener.Addr().String()
|
||||||
|
readHeaders := http.Header{"Authorization": []string{"Bearer read-token"}}
|
||||||
|
writeHeaders := http.Header{"Authorization": []string{"Bearer write-token"}}
|
||||||
|
assertStatus(t, http.MethodGet, adminURL+"/api/v1/status", readHeaders, http.StatusOK)
|
||||||
|
assertStatus(t, http.MethodPost, adminURL+"/api/v1/config/reload", readHeaders, http.StatusForbidden)
|
||||||
|
assertStatus(t, http.MethodGet, adminURL+"/api/v1/status", writeHeaders, http.StatusForbidden)
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-result:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Serve() error = %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Serve() did not stop after cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewRejectsInvalidRuntimeDependencies(t *testing.T) {
|
func TestNewRejectsInvalidRuntimeDependencies(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
validDependencies := Dependencies{
|
validDependencies := Dependencies{
|
||||||
|
|||||||
63
internal/domain/authorization/authorization.go
Normal file
63
internal/domain/authorization/authorization.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
// Package authorization owns the fixed, low-cardinality permission vocabulary
|
||||||
|
// used by HTTP authentication and endpoint handlers.
|
||||||
|
package authorization
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
All = "*"
|
||||||
|
AdminRead = "admin:read"
|
||||||
|
AdminWrite = "admin:write"
|
||||||
|
DistributionExtract = "distribution:extract"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidPermissions = errors.New("invalid permissions")
|
||||||
|
|
||||||
|
// Validate accepts an omitted permission set for backwards-compatible full
|
||||||
|
// access. An explicit set is strict, finite, duplicate-free, and cannot mix
|
||||||
|
// the global permission with narrower grants.
|
||||||
|
func Validate(permissions []string) error {
|
||||||
|
if len(permissions) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(permissions))
|
||||||
|
for _, permission := range permissions {
|
||||||
|
if strings.TrimSpace(permission) != permission || !known(permission) {
|
||||||
|
return ErrInvalidPermissions
|
||||||
|
}
|
||||||
|
if _, exists := seen[permission]; exists {
|
||||||
|
return ErrInvalidPermissions
|
||||||
|
}
|
||||||
|
seen[permission] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, hasAll := seen[All]; hasAll && len(seen) != 1 {
|
||||||
|
return ErrInvalidPermissions
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allows reports whether one authenticated identity can invoke an endpoint.
|
||||||
|
// Empty permissions preserve pre-scope configurations as full access.
|
||||||
|
func Allows(permissions []string, required string) bool {
|
||||||
|
if len(permissions) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, permission := range permissions {
|
||||||
|
if permission == All || permission == required {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func known(permission string) bool {
|
||||||
|
switch permission {
|
||||||
|
case All, AdminRead, AdminWrite, DistributionExtract:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
44
internal/domain/authorization/authorization_test.go
Normal file
44
internal/domain/authorization/authorization_test.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package authorization
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAllowsHonorsExplicitPermissionsAndLegacyEmptySet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
permissions []string
|
||||||
|
required string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "legacy empty set", required: AdminWrite, want: true},
|
||||||
|
{name: "matching permission", permissions: []string{AdminRead}, required: AdminRead, want: true},
|
||||||
|
{name: "different permission", permissions: []string{AdminRead}, required: AdminWrite, want: false},
|
||||||
|
{name: "global permission", permissions: []string{All}, required: DistributionExtract, want: true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
test := test
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := Allows(test.permissions, test.required); got != test.want {
|
||||||
|
t.Fatalf("Allows(%v, %q) = %t, want %t", test.permissions, test.required, got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsUnknownDuplicateAndMixedGlobalPermissions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, permissions := range [][]string{
|
||||||
|
{"unknown"},
|
||||||
|
{AdminRead, AdminRead},
|
||||||
|
{All, AdminRead},
|
||||||
|
{" " + AdminRead},
|
||||||
|
} {
|
||||||
|
if err := Validate(permissions); err == nil {
|
||||||
|
t.Fatalf("Validate(%v) error = nil", permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := Validate([]string{AdminRead, AdminWrite}); err != nil {
|
||||||
|
t.Fatalf("Validate(valid) error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"proxy-pool/internal/domain/authorization"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -17,14 +19,37 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type authenticator interface {
|
type authenticator interface {
|
||||||
authenticate(*http.Request, string) (string, error)
|
authenticate(*http.Request, string) (authenticationResult, error)
|
||||||
challenges() []string
|
challenges() []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type authenticationResult struct {
|
||||||
|
Principal string
|
||||||
|
Permissions []string
|
||||||
|
}
|
||||||
|
|
||||||
type noAuthenticator struct{}
|
type noAuthenticator struct{}
|
||||||
|
|
||||||
func (noAuthenticator) authenticate(*http.Request, string) (string, error) { return "", nil }
|
func (noAuthenticator) authenticate(*http.Request, string) (authenticationResult, error) {
|
||||||
func (noAuthenticator) challenges() []string { return nil }
|
return authenticationResult{}, nil
|
||||||
|
}
|
||||||
|
func (noAuthenticator) challenges() []string { return nil }
|
||||||
|
|
||||||
|
type scopedAuthenticator struct {
|
||||||
|
delegate authenticator
|
||||||
|
permissions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth scopedAuthenticator) authenticate(request *http.Request, source string) (authenticationResult, error) {
|
||||||
|
result, err := auth.delegate.authenticate(request, source)
|
||||||
|
if err != nil {
|
||||||
|
return authenticationResult{}, err
|
||||||
|
}
|
||||||
|
result.Permissions = append([]string(nil), auth.permissions...)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth scopedAuthenticator) challenges() []string { return auth.delegate.challenges() }
|
||||||
|
|
||||||
type basicAuthenticator struct {
|
type basicAuthenticator struct {
|
||||||
header string
|
header string
|
||||||
@ -32,16 +57,16 @@ type basicAuthenticator struct {
|
|||||||
password string
|
password string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth basicAuthenticator) authenticate(request *http.Request, _ string) (string, error) {
|
func (auth basicAuthenticator) authenticate(request *http.Request, _ string) (authenticationResult, error) {
|
||||||
value, ok := singleHeader(request, auth.header)
|
value, ok := singleHeader(request, auth.header)
|
||||||
username, password, parsed := parseBasicCredentials(value)
|
username, password, parsed := parseBasicCredentials(value)
|
||||||
valid := subtle.ConstantTimeSelect(boolInt(ok && parsed), 1, 0)
|
valid := subtle.ConstantTimeSelect(boolInt(ok && parsed), 1, 0)
|
||||||
valid &= secureEqual(username, auth.username)
|
valid &= secureEqual(username, auth.username)
|
||||||
valid &= secureEqual(password, auth.password)
|
valid &= secureEqual(password, auth.password)
|
||||||
if valid != 1 {
|
if valid != 1 {
|
||||||
return "", errCredentialRejected
|
return authenticationResult{}, errCredentialRejected
|
||||||
}
|
}
|
||||||
return "basic:" + auth.username, nil
|
return authenticationResult{Principal: "basic:" + auth.username}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (basicAuthenticator) challenges() []string { return []string{`Basic realm="proxy-pool"`} }
|
func (basicAuthenticator) challenges() []string { return []string{`Basic realm="proxy-pool"`} }
|
||||||
@ -52,15 +77,15 @@ type tokenAuthenticator struct {
|
|||||||
token string
|
token string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth tokenAuthenticator) authenticate(request *http.Request, _ string) (string, error) {
|
func (auth tokenAuthenticator) authenticate(request *http.Request, _ string) (authenticationResult, error) {
|
||||||
value, ok := singleHeader(request, auth.header)
|
value, ok := singleHeader(request, auth.header)
|
||||||
if auth.mode == ModeBearer {
|
if auth.mode == ModeBearer {
|
||||||
value, ok = parseScheme(value, "Bearer", ok)
|
value, ok = parseScheme(value, "Bearer", ok)
|
||||||
}
|
}
|
||||||
if !ok || secureEqual(value, auth.token) != 1 {
|
if !ok || secureEqual(value, auth.token) != 1 {
|
||||||
return "", errCredentialRejected
|
return authenticationResult{}, errCredentialRejected
|
||||||
}
|
}
|
||||||
return credentialSubject(auth.mode, auth.token), nil
|
return authenticationResult{Principal: credentialSubject(auth.mode, auth.token)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth tokenAuthenticator) challenges() []string {
|
func (auth tokenAuthenticator) challenges() []string {
|
||||||
@ -72,33 +97,33 @@ func (auth tokenAuthenticator) challenges() []string {
|
|||||||
|
|
||||||
type ipAuthenticator struct{ allowed cidrMatcher }
|
type ipAuthenticator struct{ allowed cidrMatcher }
|
||||||
|
|
||||||
func (auth ipAuthenticator) authenticate(_ *http.Request, source string) (string, error) {
|
func (auth ipAuthenticator) authenticate(_ *http.Request, source string) (authenticationResult, error) {
|
||||||
address, err := netip.ParseAddr(source)
|
address, err := netip.ParseAddr(source)
|
||||||
if err != nil || !auth.allowed.match(address) {
|
if err != nil || !auth.allowed.match(address) {
|
||||||
return "", errSourceRejected
|
return authenticationResult{}, errSourceRejected
|
||||||
}
|
}
|
||||||
return "source:" + source, nil
|
return authenticationResult{Principal: "source:" + source}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ipAuthenticator) challenges() []string { return nil }
|
func (ipAuthenticator) challenges() []string { return nil }
|
||||||
|
|
||||||
type anyAuthenticator struct{ methods []authenticator }
|
type anyAuthenticator struct{ methods []authenticator }
|
||||||
|
|
||||||
func (auth anyAuthenticator) authenticate(request *http.Request, source string) (string, error) {
|
func (auth anyAuthenticator) authenticate(request *http.Request, source string) (authenticationResult, error) {
|
||||||
sourceRejected := false
|
sourceRejected := false
|
||||||
for _, method := range auth.methods {
|
for _, method := range auth.methods {
|
||||||
principal, err := method.authenticate(request, source)
|
result, err := method.authenticate(request, source)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return principal, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
if errors.Is(err, errSourceRejected) {
|
if errors.Is(err, errSourceRejected) {
|
||||||
sourceRejected = true
|
sourceRejected = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sourceRejected {
|
if sourceRejected {
|
||||||
return "", errSourceRejected
|
return authenticationResult{}, errSourceRejected
|
||||||
}
|
}
|
||||||
return "", errCredentialRejected
|
return authenticationResult{}, errCredentialRejected
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth anyAuthenticator) challenges() []string {
|
func (auth anyAuthenticator) challenges() []string {
|
||||||
@ -110,35 +135,44 @@ func (auth anyAuthenticator) challenges() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildAuthenticator(authentication Authentication, semantics Semantics) (authenticator, error) {
|
func buildAuthenticator(authentication Authentication, semantics Semantics) (authenticator, error) {
|
||||||
|
if err := authorization.Validate(authentication.Permissions); 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 {
|
||||||
|
return nil, ErrInvalidConfig
|
||||||
|
}
|
||||||
return noAuthenticator{}, nil
|
return noAuthenticator{}, nil
|
||||||
case ModeUsernamePassword:
|
case ModeUsernamePassword:
|
||||||
if authentication.Username == "" || authentication.Password == "" {
|
if authentication.Username == "" || authentication.Password == "" {
|
||||||
return nil, ErrInvalidConfig
|
return nil, ErrInvalidConfig
|
||||||
}
|
}
|
||||||
return basicAuthenticator{header: header, username: authentication.Username, password: authentication.Password}, nil
|
return scopedAuthenticator{delegate: basicAuthenticator{header: header, username: authentication.Username, password: authentication.Password}, permissions: authentication.Permissions}, nil
|
||||||
case ModeAPIKey:
|
case ModeAPIKey:
|
||||||
if !validHeaderName(authentication.Header) || authentication.Token == "" {
|
if !validHeaderName(authentication.Header) || authentication.Token == "" {
|
||||||
return nil, ErrInvalidConfig
|
return nil, ErrInvalidConfig
|
||||||
}
|
}
|
||||||
return tokenAuthenticator{mode: ModeAPIKey, header: authentication.Header, token: authentication.Token}, nil
|
return scopedAuthenticator{delegate: tokenAuthenticator{mode: ModeAPIKey, header: authentication.Header, token: authentication.Token}, permissions: authentication.Permissions}, nil
|
||||||
case ModeBearer:
|
case ModeBearer:
|
||||||
if authentication.Token == "" {
|
if authentication.Token == "" {
|
||||||
return nil, ErrInvalidConfig
|
return nil, ErrInvalidConfig
|
||||||
}
|
}
|
||||||
return tokenAuthenticator{mode: ModeBearer, header: header, token: authentication.Token}, nil
|
return scopedAuthenticator{delegate: tokenAuthenticator{mode: ModeBearer, header: header, token: authentication.Token}, permissions: authentication.Permissions}, 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 ipAuthenticator{allowed: allowed}, nil
|
return scopedAuthenticator{delegate: ipAuthenticator{allowed: allowed}, permissions: authentication.Permissions}, nil
|
||||||
case ModeAny:
|
case ModeAny:
|
||||||
|
if len(authentication.Permissions) != 0 {
|
||||||
|
return nil, ErrInvalidConfig
|
||||||
|
}
|
||||||
if len(authentication.Methods) == 0 {
|
if len(authentication.Methods) == 0 {
|
||||||
return nil, ErrInvalidConfig
|
return nil, ErrInvalidConfig
|
||||||
}
|
}
|
||||||
@ -157,9 +191,12 @@ func buildAuthenticator(authentication Authentication, semantics Semantics) (aut
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildMethod(method Method, semantics Semantics) (authenticator, error) {
|
func buildMethod(method Method, semantics Semantics) (authenticator, error) {
|
||||||
|
if err := authorization.Validate(method.Permissions); err != nil {
|
||||||
|
return nil, ErrInvalidConfig
|
||||||
|
}
|
||||||
authentication := Authentication{
|
authentication := Authentication{
|
||||||
Mode: method.Mode, Username: method.Username, Password: method.Password,
|
Mode: method.Mode, Permissions: method.Permissions, Username: method.Username,
|
||||||
Header: method.Header, Token: method.Value, CIDRs: method.CIDRs,
|
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,25 +28,27 @@ 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,
|
||||||
Username: method.Username,
|
Permissions: append([]string(nil), method.Permissions...),
|
||||||
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,
|
||||||
Username: listener.Auth.Username,
|
Permissions: append([]string(nil), listener.Auth.Permissions...),
|
||||||
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,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"proxy-pool/internal/config"
|
"proxy-pool/internal/config"
|
||||||
|
"proxy-pool/internal/domain/authorization"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewFromListenerMapsResolvedConfiguration(t *testing.T) {
|
func TestNewFromListenerMapsResolvedConfiguration(t *testing.T) {
|
||||||
@ -17,8 +18,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"},
|
{Mode: ModeBearer, Value: "bearer-secret", Permissions: []string{authorization.AdminRead}},
|
||||||
{Mode: ModeAPIKey, Header: "X-API-Key", Value: "api-secret"},
|
{Mode: ModeAPIKey, Header: "X-API-Key", Value: "api-secret", Permissions: []string{authorization.AdminWrite}},
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
protection, err := NewFromListener(listener, ClientAuthenticated, APIAuthSemantics, nil)
|
protection, err := NewFromListener(listener, ClientAuthenticated, APIAuthSemantics, nil)
|
||||||
@ -35,6 +36,9 @@ func TestNewFromListenerMapsResolvedConfiguration(t *testing.T) {
|
|||||||
if identity.ClientID != credentialSubject("bearer", "bearer-secret") {
|
if identity.ClientID != credentialSubject("bearer", "bearer-secret") {
|
||||||
t.Fatalf("identity = %+v", identity)
|
t.Fatalf("identity = %+v", identity)
|
||||||
}
|
}
|
||||||
|
if !identity.Allows(authorization.AdminRead) || identity.Allows(authorization.AdminWrite) {
|
||||||
|
t.Fatalf("identity permissions = %v", identity.Permissions)
|
||||||
|
}
|
||||||
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,24 +73,24 @@ func (protection *Protection) evaluate(ctx context.Context, request *http.Reques
|
|||||||
return Identity{}, newHTTPError(http.StatusForbidden, "FORBIDDEN", nil, errSourceRejected)
|
return Identity{}, newHTTPError(http.StatusForbidden, "FORBIDDEN", nil, errSourceRejected)
|
||||||
}
|
}
|
||||||
source := address.String()
|
source := address.String()
|
||||||
principal, err := protection.authentication.authenticate(request, source)
|
authentication, err := protection.authentication.authenticate(request, source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errSourceRejected) {
|
if errors.Is(err, errSourceRejected) {
|
||||||
return Identity{}, newHTTPError(http.StatusForbidden, "FORBIDDEN", nil, err)
|
return Identity{}, newHTTPError(http.StatusForbidden, "FORBIDDEN", nil, err)
|
||||||
}
|
}
|
||||||
return Identity{}, protection.unauthorized(err)
|
return Identity{}, protection.unauthorized(err)
|
||||||
}
|
}
|
||||||
identity := Identity{SourceIP: source}
|
identity := Identity{SourceIP: source, Permissions: append([]string(nil), authentication.Permissions...)}
|
||||||
switch protection.clientMode {
|
switch protection.clientMode {
|
||||||
case ClientSourceIP:
|
case ClientSourceIP:
|
||||||
identity.ClientID = "source:" + source
|
identity.ClientID = "source:" + source
|
||||||
case ClientAuthenticated:
|
case ClientAuthenticated:
|
||||||
if principal == "" {
|
if authentication.Principal == "" {
|
||||||
return Identity{}, protection.unauthorized(errCredentialRejected)
|
return Identity{}, protection.unauthorized(errCredentialRejected)
|
||||||
}
|
}
|
||||||
identity.ClientID = principal
|
identity.ClientID = authentication.Principal
|
||||||
case ClientAuthenticatedOrSourceIP:
|
case ClientAuthenticatedOrSourceIP:
|
||||||
identity.ClientID = principal
|
identity.ClientID = authentication.Principal
|
||||||
if identity.ClientID == "" {
|
if identity.ClientID == "" {
|
||||||
identity.ClientID = "source:" + source
|
identity.ClientID = "source:" + source
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,9 +5,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"proxy-pool/internal/domain/authorization"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProtectionAuthenticatesAPIRequestsAndBuildsStableIdentity(t *testing.T) {
|
func TestProtectionAuthenticatesAPIRequestsAndBuildsStableIdentity(t *testing.T) {
|
||||||
@ -87,6 +90,55 @@ func TestProtectionSupportsAnyAuthentication(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProtectionCarriesPermissionsFromMatchedCredential(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
auth Authentication
|
||||||
|
configure func(*http.Request)
|
||||||
|
want []string
|
||||||
|
mustAllow string
|
||||||
|
mustReject string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single credential", auth: Authentication{
|
||||||
|
Mode: ModeBearer, Token: "read-token", Permissions: []string{authorization.AdminRead},
|
||||||
|
},
|
||||||
|
configure: func(request *http.Request) { request.Header.Set("Authorization", "Bearer read-token") },
|
||||||
|
want: []string{authorization.AdminRead}, mustAllow: authorization.AdminRead, mustReject: authorization.AdminWrite,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matched any method", auth: Authentication{Mode: ModeAny, Methods: []Method{
|
||||||
|
{Mode: ModeBearer, Value: "extract-token", Permissions: []string{authorization.DistributionExtract}},
|
||||||
|
{Mode: ModeAPIKey, Header: "X-Admin-Key", Value: "write-token", Permissions: []string{authorization.AdminWrite}},
|
||||||
|
}},
|
||||||
|
configure: func(request *http.Request) { request.Header.Set("X-Admin-Key", "write-token") },
|
||||||
|
want: []string{authorization.AdminWrite}, mustAllow: authorization.AdminWrite, mustReject: authorization.AdminRead,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
test := test
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
protection := mustProtection(t, Config{Authentication: test.auth, ClientIdentification: ClientAuthenticated}, nil)
|
||||||
|
request := newRequest()
|
||||||
|
test.configure(request)
|
||||||
|
|
||||||
|
identity, err := protection.Resolve(request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(identity.Permissions, test.want) {
|
||||||
|
t.Fatalf("permissions = %v, want %v", identity.Permissions, test.want)
|
||||||
|
}
|
||||||
|
if !identity.Allows(test.mustAllow) || identity.Allows(test.mustReject) {
|
||||||
|
t.Fatalf("permission checks for %v are wrong", identity.Permissions)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProtectionAnyPreservesSourceRejection(t *testing.T) {
|
func TestProtectionAnyPreservesSourceRejection(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"proxy-pool/internal/domain/authorization"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -29,22 +31,24 @@ var ErrInvalidConfig = errors.New("invalid HTTP security configuration")
|
|||||||
type Semantics uint8
|
type Semantics uint8
|
||||||
|
|
||||||
type Authentication struct {
|
type Authentication struct {
|
||||||
Mode string
|
Mode string
|
||||||
Username string
|
Permissions []string
|
||||||
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
|
||||||
Username string
|
Permissions []string
|
||||||
Password string
|
Username string
|
||||||
Header string
|
Password string
|
||||||
Value string
|
Header string
|
||||||
CIDRs []string
|
Value string
|
||||||
|
CIDRs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@ -56,8 +60,16 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Identity struct {
|
type Identity struct {
|
||||||
ClientID string
|
ClientID string
|
||||||
SourceIP string
|
SourceIP string
|
||||||
|
Permissions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allows reports whether this identity has the endpoint's fixed permission.
|
||||||
|
// An empty set preserves the historic full-access behavior for existing
|
||||||
|
// listener configurations that do not opt into explicit permissions.
|
||||||
|
func (identity Identity) Allows(required string) bool {
|
||||||
|
return authorization.Allows(identity.Permissions, required)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Admitter interface {
|
type Admitter interface {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user