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" { 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 } // 版本记录的同步会拉起一次对远端仓库的抓取(消耗网络与数据库写入), // 与"谁能改生产绑定"同级:读任何角色可看,同步只能管理员。 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, }) }