0974d8dfa2
build / build (push) Successful in 57s
按已确认的契约实现(口径见 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)。
208 lines
7.7 KiB
Go
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,
|
|
})
|
|
}
|