Files
knowledge-governance/internal/httpapi/auth.go
T
姚章浩 146ac832a2
build / build (push) Successful in 2m9s
fix(library): 修复封存终态回写并增加精确核对
修复 varchar 状态参数在 AdvanceLibraryJob 的 42P08;worker 开始/终态写入失败
不再吞掉。新增管理员核对路由,仅在 completed capture queue、export succeeded、
精确 test_sealed 版本、来源身份和归档复核一致时,以条件更新修复仍为
pending/running 的同一作业;已有不同快照或并发失败/取消行拒绝覆盖。保留
第一因与历史记录,不重新封存、不产生快照、不写源或目标知识。

验证:真实 varchar DDL PG 路径、handler 负例/幂等、worker 注入、web 组件;
umask022 与真实隔离 kg_library_test_* 下 Go 全量与 make verify 17 项通过。
2026-11-06 10:04:19 +08:00

222 lines
8.6 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 {
if path == "/api/v1/source-connections/audit" || path == "/api/v1/library/source-associations" {
return roleAdmin
}
// 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") || strings.HasPrefix(path, "/api/v1/qa-ops/") {
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
}
// 「去掉来源」(归档下架 / 恢复)会把一个来源从目录、计划刷新与扫描里摘掉,
// 并改变后续可见与可发布的来源集合:与改设置修订同级,写只能管理员。
if strings.HasPrefix(path, "/api/v1/source-connections/") &&
(strings.HasSuffix(path, "/archive") || strings.HasSuffix(path, "/restore")) {
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
}
// 整库备份的定时计划会落到数据面主机的 cron 上(可删数据、可占满磁盘),
// 与"谁能改生产绑定"同级:读任何角色可看,写只能管理员。
if strings.HasPrefix(path, "/api/v1/backup-schedule") && method != http.MethodGet {
return roleAdmin
}
// 核对封存结果会把一个 pending 业务作业修成 succeeded;只允许管理员,
// 且 handler 仍要求 completed queue、精确版本和归档复核。
if strings.HasPrefix(path, "/api/v1/library/jobs/") && strings.HasSuffix(path, "/reconcile-capture") {
return roleAdmin
}
// 版本记录的同步会拉起一次对远端仓库的抓取(消耗网络与数据库写入),
// 与"谁能改生产绑定"同级:读任何角色可看,同步只能管理员。
if strings.HasPrefix(path, "/api/v1/version-history") && 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,
})
}