Files
knowledge-governance/internal/httpapi/auth.go
T
姚章浩 6440e3f679 feat(release): 发布目标知识库可选(源库 → 目标库映射)
- 迁移 000044:production_kb_bindings + release_item_executions.target_knowledge_base_id
- 系统设置新增「生产知识库绑定(对内/对外)」:从生产 WeKnora 实际列表选库,
  保存时真校验(不存在拒收;生产端不可达只提示并如实回报),仅管理员可改
- 执行计划按绑定解析目标库并冻结进 release_item_executions;空目标直接拒收;
  已发布过的条目保留原目标(重试/回滚回到同一处)
- DocumentBundle.RemapKnowledgeBase:knowledges/chunks/knowledge_tags/wiki_pages/
  wiki_folders 的 knowledge_base_id 与 ES 文档字段改写为目标库,重算表/文档摘要并重新封装
- 契约比对改为「声明的映射对」(KnowledgeBaseMapping),其余字段继续 fail-closed
- 契约检查器 / 目标身份 / 验证器全部走映射后的目标库;对象通道无需改写
- Compose 拆分:数据面接线 compose.release-sim.yaml + 两道门禁 compose.release-gates.yaml
- 测试:RemapKnowledgeBase 全通道/无操作/fail-closed、映射契约、绑定接口、RBAC、真实 PG 集成
2026-10-08 15:41:13 +08:00

198 lines
7.2 KiB
Go

package httpapi
import (
"context"
"net/http"
"strings"
"time"
postgresstore "knowledge-governance/internal/platform/postgres"
)
// Session identity injected by the auth middleware. Business handlers read it
// to stamp actor fields server-side; the browser never types a name.
type sessionIdentity struct {
UserID string
Username string
DisplayName string
Role string
MustChangePassword bool
SessionID string
}
type sessionKey struct{}
func sessionFrom(ctx context.Context) (sessionIdentity, bool) {
value, ok := ctx.Value(sessionKey{}).(sessionIdentity)
return value, ok
}
const sessionCookieName = "kg_session"
const (
roleViewer = postgresstore.RoleViewer
roleEngineer = postgresstore.RoleEngineer
roleAdmin = postgresstore.RoleAdmin
)
func roleRank(role string) int {
switch role {
case roleAdmin:
return 2
case roleEngineer:
return 1
default:
return 0
}
}
// AuthStore is the account/session capability the HTTP layer needs; the
// concrete postgres *Store implements it, and fakes keep handler tests cheap.
type AuthStore interface {
Login(ctx context.Context, username, password, userAgent string, now time.Time, ttl time.Duration) (postgresstore.LoginOutcome, error)
LookupSession(ctx context.Context, token string, now time.Time) (postgresstore.AuthSession, error)
Logout(ctx context.Context, sessionID string, now time.Time) error
TouchSession(ctx context.Context, sessionID string, now, expiresAt time.Time) error
ChangePassword(ctx context.Context, userID, oldPassword, newPassword string, now time.Time) error
CreateUser(ctx context.Context, username, displayName, role, password, createdBy string, now time.Time) (postgresstore.AuthUser, error)
GetAuthUserByID(ctx context.Context, id string) (postgresstore.AuthUser, error)
ListAuthUsers(ctx context.Context) ([]postgresstore.AuthUser, error)
SetUserRole(ctx context.Context, actorID, userID, role string, now time.Time) error
SetUserState(ctx context.Context, actorID, userID, state string, now time.Time) error
AdminResetPassword(ctx context.Context, actorID, userID, temporaryPassword string, now time.Time) error
}
// AuthConfig wires the account/session store into the HTTP layer. When the
// server's Auth field is nil, auth stays disabled (unit tests and the local
// job-debug socket); production main must always provide it.
type AuthConfig struct {
Store AuthStore
Now func() time.Time
SessionTTL time.Duration
}
func (auth AuthConfig) sessionTTL() time.Duration {
if auth.SessionTTL > 0 {
return auth.SessionTTL
}
return defaultSessionTTL
}
func (auth AuthConfig) now() time.Time {
if auth.Now != nil {
return auth.Now()
}
return time.Now()
}
func isPublicEndpoint(method, path string) bool {
switch path {
case "/health", "/livez", "/readyz":
return true
}
return path == "/api/v1/auth/login" && method == http.MethodPost
}
func isAuthEndpoint(method, path string) bool {
return strings.HasPrefix(path, "/api/v1/auth/")
}
// classify maps a request to the minimum role required.
func classifyRole(method, path string) string {
// The member roster exposes account identities and roles: admin-only for
// every method, checked before the read shortcut below.
if strings.HasPrefix(path, "/api/v1/users") {
return roleAdmin
}
if method == http.MethodGet {
return roleViewer
}
// Production authority is a role, not a typed name: both double-gated
// authorizations require admin on top of the environment switches.
if strings.HasSuffix(path, "/authorize-production") || strings.HasSuffix(path, "/authorize-rollback") ||
(strings.HasPrefix(path, "/api/v1/release-executions/") && strings.HasSuffix(path, "/retry")) {
return roleAdmin
}
// Connector settings revisions are admin-only.
if strings.Contains(path, "/settings/revisions") {
return roleAdmin
}
// The DingTalk scan session replaces the shared browser credentials:
// admin-only, like every other credential surface. The QR image is a
// login artifact, so even polling it stays admin-only. Space
// registration writes new source connections: same boundary.
if strings.HasPrefix(path, "/api/v1/dingtalk-session") || strings.HasPrefix(path, "/api/v1/dingtalk-spaces") {
return roleAdmin
}
// Binding the internal/external knowledge bases re-routes every future
// annotation: admin-only, like the roster and production authority.
if path == "/api/v1/routing-bindings" && method != http.MethodGet {
return roleAdmin
}
// Choosing the production base a release writes into is a production
// authority change, exactly like the annotation binding above.
if path == "/api/v1/production-kb-bindings" && method != http.MethodGet {
return roleAdmin
}
// Everything else that mutates (review, batch, seal, confirm, start, retry,
// route visibility, disable) is engineer work; viewers cannot POST at all.
return roleEngineer
}
// authenticate wraps the mux: cookie session → context identity → lockdown →
// RBAC. Business handlers assume a valid identity exists in the context.
func (auth AuthConfig) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isPublicEndpoint(r.Method, r.URL.Path) {
next.ServeHTTP(w, r)
return
}
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
session, err := auth.Store.LookupSession(r.Context(), cookie.Value, auth.now())
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
identity := sessionIdentity{
UserID: session.User.ID, Username: session.User.Username, DisplayName: session.User.DisplayName,
Role: session.User.Role, MustChangePassword: session.User.MustChangePassword, SessionID: session.ID,
}
if session.User.MustChangePassword && !isAuthEndpoint(r.Method, r.URL.Path) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "password change required"})
return
}
if !isAuthEndpoint(r.Method, r.URL.Path) {
if roleRank(identity.Role) < roleRank(classifyRole(r.Method, r.URL.Path)) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "insufficient role"})
return
}
}
ctx := context.WithValue(r.Context(), sessionKey{}, identity)
expiresAt := auth.now().UTC().Add(auth.sessionTTL())
if err := auth.Store.TouchSession(r.Context(), session.ID, auth.now().UTC(), expiresAt); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not refresh session"})
return
}
auth.setSessionCookie(w, cookie.Value, expiresAt)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (auth AuthConfig) setSessionCookie(w http.ResponseWriter, token string, expiresAt time.Time) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Value: token, Path: "/",
Expires: expiresAt, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
}
func (auth AuthConfig) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, Expires: time.Unix(0, 0),
HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
}