Files
knowledge-governance/internal/httpapi/auth.go
T
姚章浩 3f603aa293 feat(backup): 系统设置里的存储与备份——定时时间与储存天数可配、下发到目标主机 cron
需求:整库逻辑备份做定时,但要在「系统设置 → 存储与备份」里设置定时时间与储存天数。

后端
- migration 000045 backup_schedule(单行:enabled/schedule_time/retention_days/retention_months/
  last_applied_*),store + 领域校验(HH:MM、1~365 天、0~24 月,非法值 400 且不落库不下发)
- internal/backuphost:通过 ssh 在目标主机上读回/下发计划并立即备份(未接线时 Available=false,
  页面降级提示;主机侧错误带输出回传)。最近一次只认有 MANIFEST.json 的完整目录,跳 *.incomplete
- 接口 GET/PUT /api/v1/backup-schedule、GET …/host、POST …/apply、POST …/run;
  写操作经 classifyRole 限管理员;run 为异步 202 + 在途单飞(http 写超时 30s,备份约 35s)
- openapi.yaml 补齐 5 条路由(契约测试要求每条都写 400/500)

主机侧
- scripts/install_backup_schedule.sh(幂等):写 /etc/knowledge-governance/backup-schedule.env 与
  /etc/cron.d/kb-logical-backup,参数校验,--show/--disable,最后一行 SCHEDULE_APPLIED 供平台核对
- logical-backup.sh 从该文件读保留策略;失败或被中断把目录改名 *.incomplete,不留'像成功'的半成品
- install --show 只回显计划行(原先把 SHELL=/PATH= 当计划显示)

前端
- web/src/backupSchedule.ts(严格解析:缺计划字段即报错,不把默认值伪装成设置)
- BackupScheduleSection:容器 + 纯视图(管理员表单/只读、已一致/待下发、在途轮询)

运行前提(已落实):运行镜像加 openssh-client,且容器 uid 1000 在 /etc/passwd 有名字
(否则 ssh 报 No user exists for uid 1000);凭据只读挂载 secrets/backup-host(git 忽略)

实测:主机 cron 每天 03:30 / 保留 7 天 / 每月 3 份;立即备份一次 34 秒 / 742.7 MiB / 失败 0;
Go 与前端测试全绿(170 vitest)。
2026-10-09 13:50:23 +08:00

203 lines
7.5 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
}
// 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,
})
}