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 快照流广播刷新;
定时刷新仍作为跨进程收敛与失效保护。
- **安全边界**Gateway、Distribution 与 Admin 使用各自的认证语义,并支持
CIDR、可信代理、严格请求解析和敏感信息最小化。
CIDR、可信代理、严格请求解析和敏感信息最小化Admin 的读写权限与
Distribution 提取权限可按命中凭据分别收敛。
## 架构概览

View File

@ -2,7 +2,10 @@ openapi: 3.1.0
info:
title: Proxy Pool Admin API
version: 1.0.0
description: 运维状态与受控变更接口。该入口必须与 Distribution 分端口和权限。
description: |
运维状态与受控变更接口。该入口必须与 Distribution 分端口和权限。
已认证凭据可配置 `admin:read`Status、审计和 `admin:write`(变更);
未配置权限的旧凭据保留全权限行为。
servers:
- url: http://127.0.0.1:8082
tags:
@ -22,6 +25,7 @@ paths:
tags: [Status]
operationId: getStatus
summary: 获取控制面摘要状态
description: 需要 `admin:read`。
responses:
'200':
description: 不含 Proxy 地址、Client 标识或 Secret 的聚合状态
@ -43,6 +47,8 @@ paths:
operationId: listAuditRecords
summary: 按 ID 游标读取管理面审计记录
description: |
需要 `admin:read`。
仅返回权威管理面的变更审计记录,按 `id` 升序排列。`afterId` 是排他游标:
后续页面只包含 `id` 大于该值的记录。未传 `limit` 时返回 100 条,单页最多
1000 条。
@ -78,6 +84,7 @@ paths:
tags: [Upstreams]
operationId: enableUpstream
summary: 启用 Upstream
description: 需要 `admin:write`。
parameters:
- $ref: '#/components/parameters/UpstreamName'
- $ref: '#/components/parameters/RequestID'
@ -96,6 +103,7 @@ paths:
tags: [Upstreams]
operationId: disableUpstream
summary: 禁用 Upstream 并使已有资源自然 Drain
description: 需要 `admin:write`。
parameters:
- $ref: '#/components/parameters/UpstreamName'
- $ref: '#/components/parameters/RequestID'
@ -114,6 +122,7 @@ paths:
tags: [Routing]
operationId: switchRouting
summary: 原子切换 Sequential Routing 当前 Upstream
description: 需要 `admin:write`。
parameters:
- name: name
in: path
@ -150,6 +159,7 @@ paths:
tags: [Configuration]
operationId: reloadConfiguration
summary: 严格校验并原子发布新配置快照
description: 需要 `admin:write`。
parameters:
- $ref: '#/components/parameters/RequestID'
responses:

View File

@ -7,7 +7,9 @@ info:
Redis operation atomically removes or marks every returned proxy as
extracted in the TTL activity pool. Proxy details and per-extraction
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:
- url: http://127.0.0.1:8081
description: Distribution API
@ -21,6 +23,8 @@ paths:
operationId: extractProxies
summary: 一次性独占提取代理
description: |
需要 `distribution:extract`。
服务端通过单个 Redis 原子操作完成筛选、TTL/所有权复核、Gateway 预留、
从可分配池移除所选条目和短期幂等结果写入,再返回代理。相同代理不会
返回给两个成功请求,提取路径不访问 PostgreSQL。

View File

@ -15,6 +15,14 @@ Admin API 使用独立监听器与权限,契约位于 `api/openapi/admin.yaml`
所有写操作写审计记录并返回最终 Request ID 与版本。Enable/Disable 对目标状态
幂等Routing Switch 必须携带 `expectedCurrent`,避免并发操作跳过多个供应商。
## 授权
认证成功后的权限由 Listener `auth.permissions``auth.methods[].permissions` 提供。
`admin:read` 允许 Status 与审计查询;`admin:write` 允许 Upstream 启停、Routing
切换和配置重载。每个权限独立匹配,写权限不隐含读权限;未配置权限的旧凭据保持
全权限兼容。`mode: any` 使用实际命中凭据的方法级权限。权限不足返回 `403`,不会
调用 Service、写审计或触发配置发布。
配置重载校验失败返回 422旧配置继续运行。Status 只返回低基数聚合信息,
不得返回 Proxy 地址、凭据、Client 标识或完整 Provider URL。

View File

@ -197,6 +197,11 @@ TTL 到期或 Redis 数据丢失后不再保证旧 Key 去重,系统不回退
错误响应不得包含 Provider Secret、Proxy 凭据、SQL 或内部拓扑。
`POST /api/v1/proxies/extract` 需要 `distribution:extract`。权限从 Listener 的
`auth.permissions``mode: any` 的命中 `auth.methods[].permissions` 取得;缺少权限
返回 `403`,不会读取请求体、消耗幂等键或调用提取服务。未声明权限的旧凭据保持全
权限兼容。
## 10. 短期运行记录与数据最小化
Redis 幂等结果只在配置的 TTL 窗口内保留重放响应所需的数据:

View File

@ -122,6 +122,34 @@ Lua 可精确表示的整数范围内。
- `any``methods` 中任一方法成功即可Bearer 方法的 Secret 使用
`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 是独立认证边界。改变其中一套
不得连带改变其他入口。

View File

@ -190,8 +190,8 @@ reject/wait/direct并会在快照刷新后看到停用状态。
- [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
Gateway request path on immutable local snapshots with no Redis/PostgreSQL calls.
- [x] Expose Distribution extraction/status and Admin status/enable/disable/switch/reload
HTTP handlers and contracts.
- [x] Expose Distribution extraction/status and Admin status/audit/enable/disable/switch/reload
HTTP handlers and contracts with credential-level endpoint permissions.
- [x] Add Compose-backed Redis 8.2 integration and shared Adapter contract tests.
- [x] Add PostgreSQL management Adapter and Compose-backed integration tests.

View File

@ -46,8 +46,9 @@
引用 Store 与 Reconciler Adapter 已实现。
- `DIST/Admin HTTP`:严格 JSON、Request ID、Problem 响应及 Distribution/Admin
Handler 已实现Admin 审计查询以有界 `afterId` 游标读取 PostgreSQL 权威记录,
不读取 Proxy 或 Redis 活动池。共享认证、CIDR、可信代理、Client ID 与本地准入
保护链已接入Controller Runtime 已将二者装配到独立监听器并支持联动优雅停机。
不读取 Proxy 或 Redis 活动池。共享认证、CIDR、可信代理、Client ID、凭据级
固定权限与本地准入保护链已接入Controller Runtime 已将二者装配到独立监听器
并支持联动优雅停机。
- `Redis Activity Adapter`:真实 Redis 8.2 已覆盖 Provider Upsert、健康更新、
原子独占提取、短期幂等、Worker ownership、库存和有界过期清理Memory/Redis
运行同一公用契约。
@ -125,9 +126,8 @@ Controller/Gateway 入口,完整 mTLS 运行时拓扑仍只有静态验证。
7. BASIC Checker 调度与 HTTP/HTTPS/SOCKS5 探测器、全局与 TARGET Profile 的 Memory/Redis
原子归并、Controller Reducer 和 Observation 上报 RPC 已完成EGRESS、TARGET
生产任务调度与 REMOVE 编排仍待实现。
8. Admin/Distribution 细粒度授权Distribution 分布式限流和 Admin 审计查询已完成。
9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
10. 将 reject/wait/direct 接入 Distribution 运行链。
8. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
9. 将 reject/wait/direct 接入 Distribution 运行链。
## 4. 容量结论
100,000 QPS 是集群设计输入,不是本次验证结果。只有实现上述运行时,并在

View File

@ -17,6 +17,9 @@
- `httpsecurity.Protection` 统一实现 Basic、API Key、Bearer、CIDR、可信代理链、
Client ID 与入口准入API 使用 401/`WWW-Authenticate`Gateway 使用
407/`Proxy-Authenticate`。
- Listener 可将固定权限绑定到单个认证方式,或在 `mode: any` 中绑定到每个凭据;
`admin:read`、`admin:write` 与 `distribution:extract` 在 Handler 入口完成匹配,
不把无权请求传入管理 mutation 或 Proxy 提取流程。
- Token Client ID 使用 SHA-256 的 128 位摘要前缀,不把 Token 本身写入领域、
日志或审计键。

View File

@ -79,6 +79,7 @@ type Access struct {
type Auth struct {
Mode string `yaml:"mode"`
Permissions []string `yaml:"permissions"`
Username string `yaml:"username"`
Password string `yaml:"password"`
PasswordFile string `yaml:"passwordFile"`
@ -91,6 +92,7 @@ type Auth struct {
type AuthMethod struct {
Mode string `yaml:"mode"`
Permissions []string `yaml:"permissions"`
Username string `yaml:"username"`
Password string `yaml:"password"`
PasswordFile string `yaml:"passwordFile"`

View File

@ -10,6 +10,8 @@ import (
"time"
"go.yaml.in/yaml/v4"
"proxy-pool/internal/domain/authorization"
)
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) {
t.Parallel()
cfg := mustLoadValidConfig(t)

View File

@ -80,9 +80,11 @@ func cloneListener(source Listener) Listener {
cloned.Access.AllowCIDRs = cloneStrings(source.Access.AllowCIDRs)
cloned.Access.TrustedProxies = cloneStrings(source.Access.TrustedProxies)
cloned.Auth.CIDRs = cloneStrings(source.Auth.CIDRs)
cloned.Auth.Permissions = cloneStrings(source.Auth.Permissions)
cloned.Auth.Methods = append([]AuthMethod(nil), source.Auth.Methods...)
for index := range cloned.Auth.Methods {
cloned.Auth.Methods[index].CIDRs = cloneStrings(source.Auth.Methods[index].CIDRs)
cloned.Auth.Methods[index].Permissions = cloneStrings(source.Auth.Methods[index].Permissions)
}
cloned.Retry.RetryMethods = cloneStrings(source.Retry.RetryMethods)
cloned.DestinationPolicy.DenyPrivateNetworks = cloneBool(source.DestinationPolicy.DenyPrivateNetworks)

View File

@ -8,6 +8,8 @@ import (
"regexp"
"strconv"
"strings"
"proxy-pool/internal/domain/authorization"
)
var (
@ -372,8 +374,14 @@ func validateStrategy(scope string, upstreams []string, strategy Strategy) 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 {
case "none":
if len(auth.Permissions) != 0 {
return fmt.Errorf("validate %s auth.permissions: requires an authentication mode", listener)
}
return nil
case "usernamePassword":
if auth.Username == "" || (auth.Password == "" && auth.PasswordFile == "") {
@ -395,6 +403,9 @@ func validateListenerAuth(listener string, auth Auth) error {
return err
}
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 {
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 {
if err := authorization.Validate(method.Permissions); err != nil {
return fmt.Errorf("validate %s auth.methods[%d].permissions: %w", listener, index, err)
}
switch method.Mode {
case "usernamePassword":
if method.Username == "" || (method.Password == "" && method.PasswordFile == "") {

View File

@ -10,6 +10,7 @@ import (
"time"
"proxy-pool/internal/domain/adminstate"
"proxy-pool/internal/domain/authorization"
"proxy-pool/internal/platform/httpapi"
"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) {
return
}
if !requirePermission(writer, identity, authorization.AdminRead, requestID) {
return
}
handler.getStatus(writer, request, requestID)
return
case auditPath:
if !requireMethod(writer, request, http.MethodGet, requestID) {
return
}
if !requirePermission(writer, identity, authorization.AdminRead, requestID) {
return
}
handler.readAudit(writer, request, requestID)
return
case reloadPath:
if !requireMethod(writer, request, http.MethodPost, requestID) {
return
}
if !requirePermission(writer, identity, authorization.AdminWrite, requestID) {
return
}
handler.reload(writer, request, requestID, identity)
return
}
@ -190,6 +200,9 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
if !requireMethod(writer, request, http.MethodPost, requestID) {
return
}
if !requirePermission(writer, identity, authorization.AdminWrite, requestID) {
return
}
handler.setUpstreamEnabled(writer, request, name, action == "enable", requestID, identity)
return
}
@ -197,6 +210,9 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
if !requireMethod(writer, request, http.MethodPost, requestID) {
return
}
if !requirePermission(writer, identity, authorization.AdminWrite, requestID) {
return
}
handler.switchRouting(writer, request, name, requestID, identity)
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)
}
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) {
status, err := handler.service.Status(request.Context())
if err != nil {

View File

@ -10,6 +10,7 @@ import (
"testing"
"time"
"proxy-pool/internal/domain/authorization"
"proxy-pool/internal/platform/httpapi"
"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) {
t.Parallel()
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
}
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{}
func (rejectAuthorizer) Resolve(*http.Request) (httpsecurity.Identity, error) {

View File

@ -8,6 +8,7 @@ import (
"time"
controllerExtraction "proxy-pool/internal/controller/extraction"
"proxy-pool/internal/domain/authorization"
domainExtraction "proxy-pool/internal/domain/extraction"
"proxy-pool/internal/platform/httpapi"
"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))
return
}
if !identity.Allows(authorization.DistributionExtract) {
h.writeProblem(writer, httpapi.NewProblem(http.StatusForbidden, "FORBIDDEN", "Forbidden", "", requestID))
return
}
idempotencyKey, err := validateIdempotencyKey(request.Header.Values(headerIdempotencyKey))
if err != nil {

View File

@ -12,6 +12,7 @@ import (
"time"
controllerExtraction "proxy-pool/internal/controller/extraction"
"proxy-pool/internal/domain/authorization"
domainExtraction "proxy-pool/internal/domain/extraction"
"proxy-pool/internal/platform/httpapi"
"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) {
t.Parallel()
extractor := &fakeExtractor{}

View File

@ -13,6 +13,7 @@ import (
"proxy-pool/internal/config"
"proxy-pool/internal/controller/admin"
controllerExtraction "proxy-pool/internal/controller/extraction"
"proxy-pool/internal/domain/authorization"
"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) {
t.Parallel()
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/netip"
"strings"
"proxy-pool/internal/domain/authorization"
)
var (
@ -17,14 +19,37 @@ var (
)
type authenticator interface {
authenticate(*http.Request, string) (string, error)
authenticate(*http.Request, string) (authenticationResult, error)
challenges() []string
}
type authenticationResult struct {
Principal string
Permissions []string
}
type noAuthenticator struct{}
func (noAuthenticator) authenticate(*http.Request, string) (string, error) { return "", nil }
func (noAuthenticator) challenges() []string { return nil }
func (noAuthenticator) authenticate(*http.Request, string) (authenticationResult, error) {
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 {
header string
@ -32,16 +57,16 @@ type basicAuthenticator struct {
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)
username, password, parsed := parseBasicCredentials(value)
valid := subtle.ConstantTimeSelect(boolInt(ok && parsed), 1, 0)
valid &= secureEqual(username, auth.username)
valid &= secureEqual(password, auth.password)
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"`} }
@ -52,15 +77,15 @@ type tokenAuthenticator struct {
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)
if auth.mode == ModeBearer {
value, ok = parseScheme(value, "Bearer", ok)
}
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 {
@ -72,33 +97,33 @@ func (auth tokenAuthenticator) challenges() []string {
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)
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 }
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
for _, method := range auth.methods {
principal, err := method.authenticate(request, source)
result, err := method.authenticate(request, source)
if err == nil {
return principal, nil
return result, nil
}
if errors.Is(err, errSourceRejected) {
sourceRejected = true
}
}
if sourceRejected {
return "", errSourceRejected
return authenticationResult{}, errSourceRejected
}
return "", errCredentialRejected
return authenticationResult{}, errCredentialRejected
}
func (auth anyAuthenticator) challenges() []string {
@ -110,35 +135,44 @@ func (auth anyAuthenticator) challenges() []string {
}
func buildAuthenticator(authentication Authentication, semantics Semantics) (authenticator, error) {
if err := authorization.Validate(authentication.Permissions); err != nil {
return nil, ErrInvalidConfig
}
header := "Authorization"
if semantics == ProxySemantics {
header = "Proxy-Authorization"
}
switch authentication.Mode {
case "", ModeNone:
if len(authentication.Permissions) != 0 {
return nil, ErrInvalidConfig
}
return noAuthenticator{}, nil
case ModeUsernamePassword:
if authentication.Username == "" || authentication.Password == "" {
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:
if !validHeaderName(authentication.Header) || authentication.Token == "" {
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:
if authentication.Token == "" {
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:
allowed, err := newCIDRMatcher(authentication.CIDRs)
if err != nil || len(authentication.CIDRs) == 0 {
return nil, ErrInvalidConfig
}
return ipAuthenticator{allowed: allowed}, nil
return scopedAuthenticator{delegate: ipAuthenticator{allowed: allowed}, permissions: authentication.Permissions}, nil
case ModeAny:
if len(authentication.Permissions) != 0 {
return nil, ErrInvalidConfig
}
if len(authentication.Methods) == 0 {
return nil, ErrInvalidConfig
}
@ -157,9 +191,12 @@ func buildAuthenticator(authentication Authentication, semantics Semantics) (aut
}
func buildMethod(method Method, semantics Semantics) (authenticator, error) {
if err := authorization.Validate(method.Permissions); err != nil {
return nil, ErrInvalidConfig
}
authentication := Authentication{
Mode: method.Mode, Username: method.Username, Password: method.Password,
Header: method.Header, Token: method.Value, CIDRs: method.CIDRs,
Mode: method.Mode, Permissions: method.Permissions, Username: method.Username,
Password: method.Password, Header: method.Header, Token: method.Value, CIDRs: method.CIDRs,
}
if method.Mode == ModeAny || method.Mode == ModeNone || method.Mode == "" {
return nil, ErrInvalidConfig

View File

@ -28,25 +28,27 @@ func NewFromListener(listener config.Listener, clientIdentification string, sema
methods := make([]Method, 0, len(listener.Auth.Methods))
for _, method := range listener.Auth.Methods {
methods = append(methods, Method{
Mode: method.Mode,
Username: method.Username,
Password: method.Password,
Header: method.Header,
Value: method.Value,
CIDRs: append([]string(nil), method.CIDRs...),
Mode: method.Mode,
Permissions: append([]string(nil), method.Permissions...),
Username: method.Username,
Password: method.Password,
Header: method.Header,
Value: method.Value,
CIDRs: append([]string(nil), method.CIDRs...),
})
}
return New(Config{
TrustedProxies: append([]string(nil), listener.Access.TrustedProxies...),
AllowCIDRs: append([]string(nil), listener.Access.AllowCIDRs...),
Authentication: Authentication{
Mode: listener.Auth.Mode,
Username: listener.Auth.Username,
Password: listener.Auth.Password,
Header: listener.Auth.Header,
Token: listener.Auth.Token,
CIDRs: append([]string(nil), listener.Auth.CIDRs...),
Methods: methods,
Mode: listener.Auth.Mode,
Permissions: append([]string(nil), listener.Auth.Permissions...),
Username: listener.Auth.Username,
Password: listener.Auth.Password,
Header: listener.Auth.Header,
Token: listener.Auth.Token,
CIDRs: append([]string(nil), listener.Auth.CIDRs...),
Methods: methods,
},
ClientIdentification: clientIdentification,
Semantics: semantics,

View File

@ -7,6 +7,7 @@ import (
"testing"
"proxy-pool/internal/config"
"proxy-pool/internal/domain/authorization"
)
func TestNewFromListenerMapsResolvedConfiguration(t *testing.T) {
@ -17,8 +18,8 @@ func TestNewFromListenerMapsResolvedConfiguration(t *testing.T) {
TrustedProxies: []string{"10.0.0.0/8"},
},
Auth: config.Auth{Mode: ModeAny, Methods: []config.AuthMethod{
{Mode: ModeBearer, Value: "bearer-secret"},
{Mode: ModeAPIKey, Header: "X-API-Key", Value: "api-secret"},
{Mode: ModeBearer, Value: "bearer-secret", Permissions: []string{authorization.AdminRead}},
{Mode: ModeAPIKey, Header: "X-API-Key", Value: "api-secret", Permissions: []string{authorization.AdminWrite}},
}},
}
protection, err := NewFromListener(listener, ClientAuthenticated, APIAuthSemantics, nil)
@ -35,6 +36,9 @@ func TestNewFromListenerMapsResolvedConfiguration(t *testing.T) {
if identity.ClientID != credentialSubject("bearer", "bearer-secret") {
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 {
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)
}
source := address.String()
principal, err := protection.authentication.authenticate(request, source)
authentication, err := protection.authentication.authenticate(request, source)
if err != nil {
if errors.Is(err, errSourceRejected) {
return Identity{}, newHTTPError(http.StatusForbidden, "FORBIDDEN", nil, err)
}
return Identity{}, protection.unauthorized(err)
}
identity := Identity{SourceIP: source}
identity := Identity{SourceIP: source, Permissions: append([]string(nil), authentication.Permissions...)}
switch protection.clientMode {
case ClientSourceIP:
identity.ClientID = "source:" + source
case ClientAuthenticated:
if principal == "" {
if authentication.Principal == "" {
return Identity{}, protection.unauthorized(errCredentialRejected)
}
identity.ClientID = principal
identity.ClientID = authentication.Principal
case ClientAuthenticatedOrSourceIP:
identity.ClientID = principal
identity.ClientID = authentication.Principal
if identity.ClientID == "" {
identity.ClientID = "source:" + source
}

View File

@ -5,9 +5,12 @@ import (
"errors"
"net/http"
"net/http/httptest"
"slices"
"sync"
"sync/atomic"
"testing"
"proxy-pool/internal/domain/authorization"
)
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) {
t.Parallel()
tests := []struct {

View File

@ -4,6 +4,8 @@ import (
"context"
"errors"
"net/http"
"proxy-pool/internal/domain/authorization"
)
const (
@ -29,22 +31,24 @@ var ErrInvalidConfig = errors.New("invalid HTTP security configuration")
type Semantics uint8
type Authentication struct {
Mode string
Username string
Password string
Header string
Token string
CIDRs []string
Methods []Method
Mode string
Permissions []string
Username string
Password string
Header string
Token string
CIDRs []string
Methods []Method
}
type Method struct {
Mode string
Username string
Password string
Header string
Value string
CIDRs []string
Mode string
Permissions []string
Username string
Password string
Header string
Value string
CIDRs []string
}
type Config struct {
@ -56,8 +60,16 @@ type Config struct {
}
type Identity struct {
ClientID string
SourceIP string
ClientID 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 {