diff --git a/README.md b/README.md index 4b6751f..41c6fc9 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ Proxy Pool 用 Controller 协调这些变化,并让 Gateway 数据面只消费 Upstream 启停、Routing 切换和配置发布会向本进程全部在线 Worker 快照流广播刷新; 定时刷新仍作为跨进程收敛与失效保护。 - **安全边界**:Gateway、Distribution 与 Admin 使用各自的认证语义,并支持 - CIDR、可信代理、严格请求解析和敏感信息最小化。 + CIDR、可信代理、严格请求解析和敏感信息最小化;Admin 的读写权限与 + Distribution 提取权限可按命中凭据分别收敛。 ## 架构概览 diff --git a/api/openapi/admin.yaml b/api/openapi/admin.yaml index 1f815e8..d321017 100644 --- a/api/openapi/admin.yaml +++ b/api/openapi/admin.yaml @@ -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: diff --git a/api/openapi/proxy-pool.yaml b/api/openapi/proxy-pool.yaml index 9b6b490..c42b391 100644 --- a/api/openapi/proxy-pool.yaml +++ b/api/openapi/proxy-pool.yaml @@ -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。 diff --git a/docs/api/admin.md b/docs/api/admin.md index 9967a23..4e94ceb 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -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。 diff --git a/docs/api/distribution.md b/docs/api/distribution.md index e0aa92e..bc114b3 100644 --- a/docs/api/distribution.md +++ b/docs/api/distribution.md @@ -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 窗口内保留重放响应所需的数据: diff --git a/docs/configuration/reference.md b/docs/configuration/reference.md index 6680572..1e6810a 100644 --- a/docs/configuration/reference.md +++ b/docs/configuration/reference.md @@ -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 是独立认证边界。改变其中一套 不得连带改变其他入口。 diff --git a/docs/development/implementation-plan.md b/docs/development/implementation-plan.md index fbcc5ee..d901d55 100644 --- a/docs/development/implementation-plan.md +++ b/docs/development/implementation-plan.md @@ -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. diff --git a/docs/requirements/completion-audit.md b/docs/requirements/completion-audit.md index 83fd082..ff84a58 100644 --- a/docs/requirements/completion-audit.md +++ b/docs/requirements/completion-audit.md @@ -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 是集群设计输入,不是本次验证结果。只有实现上述运行时,并在 diff --git a/docs/security/security-model.md b/docs/security/security-model.md index f06a5e3..8414c6a 100644 --- a/docs/security/security-model.md +++ b/docs/security/security-model.md @@ -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 本身写入领域、 日志或审计键。 diff --git a/internal/config/config.go b/internal/config/config.go index 9dc2c13..ae92d8b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1cb2114..e6ec5c5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) diff --git a/internal/config/redact.go b/internal/config/redact.go index 450a473..d4caa7d 100644 --- a/internal/config/redact.go +++ b/internal/config/redact.go @@ -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) diff --git a/internal/config/validate.go b/internal/config/validate.go index 299c4b5..a0fa9c0 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -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 == "") { diff --git a/internal/controller/admin/handler.go b/internal/controller/admin/handler.go index 66487a9..d0a67c0 100644 --- a/internal/controller/admin/handler.go +++ b/internal/controller/admin/handler.go @@ -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 { diff --git a/internal/controller/admin/handler_test.go b/internal/controller/admin/handler_test.go index 04f0174..7def178 100644 --- a/internal/controller/admin/handler_test.go +++ b/internal/controller/admin/handler_test.go @@ -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) { diff --git a/internal/controller/distribution/handler.go b/internal/controller/distribution/handler.go index 471e1d5..f26d732 100644 --- a/internal/controller/distribution/handler.go +++ b/internal/controller/distribution/handler.go @@ -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 { diff --git a/internal/controller/distribution/handler_test.go b/internal/controller/distribution/handler_test.go index 28a2a6a..a6bae54 100644 --- a/internal/controller/distribution/handler_test.go +++ b/internal/controller/distribution/handler_test.go @@ -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{} diff --git a/internal/controller/runtime/runtime_test.go b/internal/controller/runtime/runtime_test.go index 461b863..75b8e3d 100644 --- a/internal/controller/runtime/runtime_test.go +++ b/internal/controller/runtime/runtime_test.go @@ -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{ diff --git a/internal/domain/authorization/authorization.go b/internal/domain/authorization/authorization.go new file mode 100644 index 0000000..e3db42e --- /dev/null +++ b/internal/domain/authorization/authorization.go @@ -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 + } +} diff --git a/internal/domain/authorization/authorization_test.go b/internal/domain/authorization/authorization_test.go new file mode 100644 index 0000000..8a69e37 --- /dev/null +++ b/internal/domain/authorization/authorization_test.go @@ -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) + } +} diff --git a/internal/platform/httpsecurity/auth.go b/internal/platform/httpsecurity/auth.go index db6d8dd..7ec96aa 100644 --- a/internal/platform/httpsecurity/auth.go +++ b/internal/platform/httpsecurity/auth.go @@ -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 diff --git a/internal/platform/httpsecurity/config.go b/internal/platform/httpsecurity/config.go index ab9e6b7..6171753 100644 --- a/internal/platform/httpsecurity/config.go +++ b/internal/platform/httpsecurity/config.go @@ -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, diff --git a/internal/platform/httpsecurity/config_test.go b/internal/platform/httpsecurity/config_test.go index 8739a8d..83a2653 100644 --- a/internal/platform/httpsecurity/config_test.go +++ b/internal/platform/httpsecurity/config_test.go @@ -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") } diff --git a/internal/platform/httpsecurity/protection.go b/internal/platform/httpsecurity/protection.go index 1096406..d85dff2 100644 --- a/internal/platform/httpsecurity/protection.go +++ b/internal/platform/httpsecurity/protection.go @@ -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 } diff --git a/internal/platform/httpsecurity/security_test.go b/internal/platform/httpsecurity/security_test.go index 22a934c..2bf9120 100644 --- a/internal/platform/httpsecurity/security_test.go +++ b/internal/platform/httpsecurity/security_test.go @@ -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 { diff --git a/internal/platform/httpsecurity/types.go b/internal/platform/httpsecurity/types.go index 01454d3..bbd71c1 100644 --- a/internal/platform/httpsecurity/types.go +++ b/internal/platform/httpsecurity/types.go @@ -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 {