Files
Cyrene_v2/internal/auth/rbac.go
T
AskaEth adef36538f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(auth,gateway,user): X-API-Key 真接线 + key 一律超管 + owner 档案真落盘(裁决 15.4.2/15.4.3/10.4.5/08.4.2)
- 15.4.2:管理 API 与 Bearer 共用同一 Authorize 路径,X-API-Key 命中签发表即过门;未知/已吊销/空值 401 且不回落 Bearer(fail-closed)
- 15.4.3:key 表去掉角色维度,任一有效 key 鉴权后即 owner;签发接口取消 role 分级(不再读请求体)
- 10.4.5:新增 GET/PUT /api/v1/admin/owner-profile(owner-only,PUT 带审计 owner.profile.update),0600 原子落盘 + 活档案热替换;前端主人档案改接真实 API,读不到即禁用保存,删掉硬编码默认档案
- 08.4.2:persona 的 identity.systemPrompt 必填,缺失/空白 400 显式报错,不再派生回落
2026-10-04 12:07:51 +08:00

194 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package auth
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
)
// claimsCtxKey is the context key for a validated Claims set by Authorize.
type claimsCtxKey struct{}
// ClaimsFromContext returns the validated Claims stored by Authorize.
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
claims, ok := ctx.Value(claimsCtxKey{}).(*Claims)
return claims, ok
}
// apiKeyHeader 是 API key 凭据的请求头(裁决 15.4.2)。
const apiKeyHeader = "X-API-Key"
// apiKeySubPrefix 是 API key 分支写入 Claims.Sub 的前缀。Sub 取**脱敏** id
// 而不是明文,使审计链能定位是哪把 key,却永不记录密钥材料。
const apiKeySubPrefix = "apikey:"
// KeyTable 是 API key 表的最小读接口(*APIKey 满足),使下面的中间件不依赖
// 具体存储实现。
type KeyTable interface {
// Lookup 报告 key 是否已登记,并返回其授权角色。
Lookup(key string) (Role, bool)
}
// Authorize returns an HTTP middleware that requires a valid Bearer JWT whose
// role satisfies at least one of the required roles. With no required roles it
// admits any authenticated call. A missing/invalid/expired token yields 401
// (ErrUnauthorized); an authenticated token with insufficient privilege yields
// 403 (ErrForbidden). The validated claims are stored in the request context
// for downstream handlers.
//
// 本方法只认 Bearer;`X-API-Key` 凭据由 WithAPIKey 叠加(裁决 15.4.2),
// 两条分支共用同一段角色判定与上下文注入逻辑(claimGate)。
func (s *JWTService) Authorize(required ...Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := bearerToken(r)
if !ok {
s.log.Debug("missing bearer token")
writeAuthError(w, http.StatusUnauthorized, ErrUnauthorized)
return
}
claims, err := s.Verify(token)
if err != nil {
s.log.Debug("token verify failed", "err", err)
writeAuthError(w, http.StatusUnauthorized, ErrUnauthorized)
return
}
claimGate{log: s.log, required: required}.admit(w, r, claims, next)
})
}
}
// WithAPIKey 在既有凭据门 bearer 之上叠加 `X-API-Key` 分支(裁决 15.4.2),
// 使签发出去的 key 真的能用:
//
// - 请求带 `X-API-Key` 头时**只**走 key 分支 —— 命中 keys 表即以 owner 级
// 过门(裁决 15.4.3「key 一律超级管理员」,不做角色分档;owner 满足一切
// 角色要求);为空值、未知或已吊销一律 401(fail-closed),**绝不回落到
// Bearer**,因此一把坏 key 无法借道另一份凭据混进门;
// - 不带该头时完全交给 bearer,Bearer JWT 路径的行为一字不变。
//
// keys 为 nil 表示 key 表未注入(等同空表):此时带头的请求一律 401。
func WithAPIKey(
keys KeyTable,
bearer func(http.Handler) http.Handler,
log *slog.Logger,
required ...Role,
) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
// Bearer 门在注册期构建一次(与原 Authorize 的分配方式一致),
// 不走 key 分支的请求照旧零额外分配。
bearerDoor := bearer(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key, present := apiKeyFromRequest(r)
if !present {
bearerDoor.ServeHTTP(w, r)
return
}
claims, ok := lookupAPIKeyClaims(keys, key)
if !ok {
log.Debug("api key rejected", "reason", "absent table, unknown or revoked key")
writeAuthError(w, http.StatusUnauthorized, ErrUnauthorized)
return
}
claimGate{log: log, required: required}.admit(w, r, claims, next)
})
}
}
// claimGate 是两种凭据(Bearer JWT 与 X-API-Key)共用的后半段:角色判定 →
// 403/放行 → 已验证 claims 进请求上下文。这正是裁决 15.4.2 要求的「与 Bearer
// 走同一 Authorize 路径」。
type claimGate struct {
log *slog.Logger
required []Role
}
// admit 在 claims 满足 required 时把 claims 放进请求上下文并交给 next,
// 否则回 403。
func (g claimGate) admit(w http.ResponseWriter, r *http.Request, claims *Claims, next http.Handler) {
if !satisfies(claims.Role, g.required) {
g.log.Debug("insufficient role", "role", claims.Role)
writeAuthError(w, http.StatusForbidden, ErrForbidden)
return
}
ctx := context.WithValue(r.Context(), claimsCtxKey{}, claims)
next.ServeHTTP(w, r.WithContext(ctx))
}
// apiKeyFromRequest 取出 `X-API-Key` 头的值。present 报告该头是否**出现**
// (即使值为空)——出现即表示调用方选择了 key 凭据,因此空值也按无效凭据
// 拒绝,而不是悄悄回落到 Bearer。
func apiKeyFromRequest(r *http.Request) (string, bool) {
values := r.Header.Values(apiKeyHeader)
if len(values) == 0 {
return "", false
}
return strings.TrimSpace(values[0]), true
}
// lookupAPIKeyClaims 校验 key 并映射为 Claims。按裁决 15.4.3,任何已登记且
// 未吊销的 key 都是超管级(由表给出角色,见 APIKey.Lookup)。空 key 与「表未
// 注入」都直接失败:fail-closed。
func lookupAPIKeyClaims(keys KeyTable, key string) (*Claims, bool) {
if keys == nil || key == "" {
return nil, false
}
role, ok := keys.Lookup(key)
if !ok {
return nil, false
}
return &Claims{Sub: apiKeySubPrefix + MaskKey(key), Role: role}, true
}
// satisfies reports whether role has enough privilege for at least one of the
// required roles. An empty required list means any authenticated role passes.
func satisfies(role Role, required []Role) bool {
if len(required) == 0 {
return true
}
for _, r := range required {
if HasRole(role, r) {
return true
}
}
return false
}
// bearerToken extracts the token from an Authorization: Bearer header.
func bearerToken(r *http.Request) (string, bool) {
const prefix = "Bearer "
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, prefix) {
return "", false
}
token := strings.TrimSpace(h[len(prefix):])
return token, token != ""
}
// writeAuthError writes an {error:{code,message}} JSON response matching the
// project's client contract. No token material is ever logged.
func writeAuthError(w http.ResponseWriter, status int, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"code": authErrorCode(status),
"message": err.Error(),
},
})
}
// authErrorCode maps an HTTP status to a stable error code string.
func authErrorCode(status int) string {
switch status {
case http.StatusUnauthorized:
return "unauthorized"
case http.StatusForbidden:
return "forbidden"
default:
return http.StatusText(status)
}
}