Files
knowledge-governance/internal/httpapi/auth.go
T
姚章浩 0974d8dfa2
build / build (push) Successful in 57s
feat(version-history): 「版本记录」页 —— 追踪远端 main 全部提交(只读 + 管理员手动同步)
按已确认的契约实现(口径见 docs/VERSION_HISTORY.md):

- **版本号口径(只给有证据的)**:注释 tag + tag 指向该提交 + 该提交内 CHANGELOG 顶部三者一致 ⇒ 正式版本;
  tag 之后的提交按 `<主>.<次>.<自该 tag 的提交数>` 显示开发号(与 scripts/version.py **同规则**);
  证据不足 ⇒ 「历史提交 · 短SHA」+ 原因码,**不补造版本号**。完整 SHA 是唯一身份、版本号不是唯一键
  (tag 之后两条并行分支各自的第 1 个提交都算 0.1.1 ⇒ 两条都保留,用短 SHA 区分)。
- **迁移 000046**:version_history_commits(提交记录,主键 repo_key+commit_sha)+ version_history_sync_runs
  (同步状态)。快照 = 一次同步:抓完并与 X-Total-Count 对账通过才切换;失败不切换快照、不清空历史、
  last_success_at 不动(页面显示"上次同步失败(原因)· 仍展示上次成功数据");「同步成功」≠「历史完整」。
- **只读客户端(匿名,不使用任何凭据)**:白名单 origin + 固定仓库、拒绝任意 URL 与跨主机重定向、
  响应上限 5MB / 超时 15s、仅 5xx/429 退避重试(最多 3 次);逐页抓到短页为止;每次全量扫 tag
  ⇒ 旧提交后补的 tag 会更新既有记录(不新增第二行)。
- **接口**(三个都登记进 api/openapi.yaml):GET /api/v1/build-info(viewer)、
  GET /api/v1/version-history(viewer;游标绑定快照,过期返回 409 cursor_expired;不返回伪造 total)、
  POST /api/v1/version-history/sync(admin;并发同步复用同一作业,靠 IdempotencyKey)。
- **「当前运行」只认精确匹配**:产物注入的完整 SHA 命中某条记录才标该条;开发构建显式标注"开发构建";
  没有可用标识 ⇒ "当前运行版本未知";**不拿最新 main / 最新 tag 顶替**,CI 成功不等于已部署。
- **前端**:一级菜单第 5 项(版本记录)+ 时间线页面(全部/正式/开发/历史四段筛选、正文展开、
  加载更多、760px 单列无横向溢出、提交正文与变更记录按纯文本渲染);无打 tag / 发布 / 部署入口。

新增配置(留空即未接线,页面降级):VERSION_HISTORY_GITEA_BASE_URL / _REPO / _ALLOWED_ORIGINS /
_HTTP_TIMEOUT / _MAX_PAGES。

验证:`make verify` 15/15 success(tests-go 578 通过 / 0 失败;tests-web 209 通过 / 0 失败);
新增 Go 用例 30(版本口径与图遍历 12、客户端守卫 6、同步编排 5、接口 7);前端新增 18(组件 10 + 客户端 8);
真实仓库匿名快照拉取(`VERSION_HISTORY_LIVE_BASE_URL=… go test -run TestLiveSyncAgainstRealRepository`):
抓到 113 条与 X-Total-Count 一致、v0.1.0 是注释 tag 且目标与 `git rev-parse v0.1.0^{commit}` 一致、
开发号逐个等于 `git rev-list --count v0.1.0..<sha>`(正式 1 / 开发 22 / 历史 90)。
2026-10-13 19:40:19 +08:00

208 lines
7.7 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
}
// 整库备份的定时计划会落到数据面主机的 cron 上(可删数据、可占满磁盘),
// 与"谁能改生产绑定"同级:读任何角色可看,写只能管理员。
if strings.HasPrefix(path, "/api/v1/backup-schedule") && method != http.MethodGet {
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,
})
}