feat: enforce credential endpoint permissions

This commit is contained in:
youfak 2026-08-02 15:50:23 +08:00
parent ec3ceb2c9a
commit aaed288211
26 changed files with 530 additions and 68 deletions

View File

@ -78,7 +78,8 @@ Proxy Pool 用 Controller 协调这些变化,并让 Gateway 数据面只消费
Upstream 启停、Routing 切换和配置发布会向本进程全部在线 Worker 快照流广播刷新; Upstream 启停、Routing 切换和配置发布会向本进程全部在线 Worker 快照流广播刷新;
定时刷新仍作为跨进程收敛与失效保护。 定时刷新仍作为跨进程收敛与失效保护。
- **安全边界**Gateway、Distribution 与 Admin 使用各自的认证语义,并支持 - **安全边界**Gateway、Distribution 与 Admin 使用各自的认证语义,并支持
CIDR、可信代理、严格请求解析和敏感信息最小化。 CIDR、可信代理、严格请求解析和敏感信息最小化Admin 的读写权限与
Distribution 提取权限可按命中凭据分别收敛。
## 架构概览 ## 架构概览

View File

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

View File

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

View File

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

View File

@ -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 窗口内保留重放响应所需的数据:

View File

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

View File

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

View File

@ -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 是集群设计输入,不是本次验证结果。只有实现上述运行时,并在

View File

@ -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 本身写入领域、
日志或审计键。 日志或审计键。

View File

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

View File

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

View File

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

View File

@ -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 == "") {

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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