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 快照流广播刷新;
|
||||
定时刷新仍作为跨进程收敛与失效保护。
|
||||
- **安全边界**:Gateway、Distribution 与 Admin 使用各自的认证语义,并支持
|
||||
CIDR、可信代理、严格请求解析和敏感信息最小化。
|
||||
CIDR、可信代理、严格请求解析和敏感信息最小化;Admin 的读写权限与
|
||||
Distribution 提取权限可按命中凭据分别收敛。
|
||||
|
||||
## 架构概览
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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。
|
||||
|
||||
|
||||
@ -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 窗口内保留重放响应所需的数据:
|
||||
|
||||
@ -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 是独立认证边界。改变其中一套
|
||||
不得连带改变其他入口。
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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 是集群设计输入,不是本次验证结果。只有实现上述运行时,并在
|
||||
|
||||
@ -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 本身写入领域、
|
||||
日志或审计键。
|
||||
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 == "") {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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{
|
||||
|
||||
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/netip"
|
||||
"strings"
|
||||
|
||||
"proxy-pool/internal/domain/authorization"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -17,31 +19,54 @@ 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) 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
|
||||
username 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)
|
||||
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
|
||||
|
||||
@ -29,6 +29,7 @@ func NewFromListener(listener config.Listener, clientIdentification string, sema
|
||||
for _, method := range listener.Auth.Methods {
|
||||
methods = append(methods, Method{
|
||||
Mode: method.Mode,
|
||||
Permissions: append([]string(nil), method.Permissions...),
|
||||
Username: method.Username,
|
||||
Password: method.Password,
|
||||
Header: method.Header,
|
||||
@ -41,6 +42,7 @@ func NewFromListener(listener config.Listener, clientIdentification string, sema
|
||||
AllowCIDRs: append([]string(nil), listener.Access.AllowCIDRs...),
|
||||
Authentication: Authentication{
|
||||
Mode: listener.Auth.Mode,
|
||||
Permissions: append([]string(nil), listener.Auth.Permissions...),
|
||||
Username: listener.Auth.Username,
|
||||
Password: listener.Auth.Password,
|
||||
Header: listener.Auth.Header,
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"proxy-pool/internal/domain/authorization"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -30,6 +32,7 @@ type Semantics uint8
|
||||
|
||||
type Authentication struct {
|
||||
Mode string
|
||||
Permissions []string
|
||||
Username string
|
||||
Password string
|
||||
Header string
|
||||
@ -40,6 +43,7 @@ type Authentication struct {
|
||||
|
||||
type Method struct {
|
||||
Mode string
|
||||
Permissions []string
|
||||
Username string
|
||||
Password string
|
||||
Header string
|
||||
@ -58,6 +62,14 @@ type Config struct {
|
||||
type Identity struct {
|
||||
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user