Files
knowledge-governance/internal/httpapi/library_authorization.go
T
姚章浩 7b8cc0384b
build / build (push) Successful in 1m19s
feat(library): 生产发布授权与同步/恢复编排,worker 接线
为什么:同步入口此前只会拒绝;授权、预备份、四类写入、验证与失败恢复都没有代码。
本轮把编排与授权实现出来,真实生产执行仍按授权闸口关闭。

- internal/libraryrelease:预备份 → 数据库/资源/索引/Wiki → 验证 → 失败恢复;
  授权过期/目标不符/快照不符/参数变化在动生产前拒绝;恢复失败单独上报。
- 迁移 000067 library_release_authorizations(目标+快照+参数摘要+有效期,可撤销)。
- POST/GET /api/v1/library/authorizations 与 /{id}/revoke,仅管理员签发,5–1440 分钟。
- worker library.sync 接编排:先闸口再授权,阶段落库,只有编排成功才标成功。
- 生产目标适配器未配置时显式失败(不返回零值状态),预备份存储同。
- overview 返回授权状态,页面"生产保护与恢复"显示签发人与有效期。
- 验收矩阵区分"已实现·隔离测试通过"与"真实执行未授权"。
2026-10-28 15:11:27 +08:00

135 lines
5.3 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"knowledge-governance/internal/governance"
)
// 生产发布授权:管理员把"目标 + 快照 + 参数摘要 + 有效期"钉死。
// 换快照、换目标或改参数都会得到不同摘要,旧授权自然失效;到期或被撤销也不能用。
type libraryAuthorizationRequest struct {
Scope string `json:"scope"`
SnapshotID string `json:"snapshot_id"`
ExpiresInMinutes int `json:"expires_in_minutes"`
}
type libraryAuthorizationStore interface {
CreateLibraryAuthorization(ctx context.Context, item governance.LibraryReleaseAuthorization) error
LibraryAuthorizations(ctx context.Context, scope string, limit int) ([]governance.LibraryReleaseAuthorization, error)
RevokeLibraryAuthorization(ctx context.Context, id string, now time.Time) error
}
func libraryAuthorizationStoreOf(config Config) (libraryAuthorizationStore, bool) {
store, ok := config.Store.(libraryAuthorizationStore)
return store, ok
}
// librarySyncParamsDigest 与 POST /library/syncs 使用的摘要必须完全一致,
// 否则授权永远匹配不上,或者更糟——能匹配上却对应别的参数。
func librarySyncParamsDigest(scope, snapshotID string) (string, error) {
return governance.LibraryRequestDigest(scope, governance.LibraryJobSync,
map[string]any{"target_snapshot_id": snapshotID})
}
func libraryAuthorizationCreate(w http.ResponseWriter, r *http.Request, config Config) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
identity, ok := sessionFrom(r.Context())
if !ok || roleRank(identity.Role) < roleRank(roleAdmin) {
writeJSON(w, http.StatusForbidden, map[string]any{"error": "生产发布授权仅管理员可签发"})
return
}
store, ok := libraryAuthorizationStoreOf(config)
if !ok {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "授权存储不可用"})
return
}
var request libraryAuthorizationRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&request); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if request.Scope != "internal" && request.Scope != "external" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "scope must be internal or external"})
return
}
if strings.TrimSpace(request.SnapshotID) == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "snapshot_id required"})
return
}
// 有效期必须显式给出且很短:不提供"永久授权"这种危险默认值。
if request.ExpiresInMinutes < 5 || request.ExpiresInMinutes > 24*60 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expires_in_minutes must be between 5 and 1440"})
return
}
digest, err := librarySyncParamsDigest(request.Scope, request.SnapshotID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot digest params"})
return
}
now := time.Now().UTC()
item := governance.LibraryReleaseAuthorization{
ID: "libauth-" + request.Scope + "-" + now.Format("20060102T150405.000000"),
Scope: request.Scope,
SnapshotID: request.SnapshotID,
ParamsDigest: digest,
AuthorizedBy: identity.Username,
ExpiresAt: now.Add(time.Duration(request.ExpiresInMinutes) * time.Minute),
CreatedAt: now,
}
if err := store.CreateLibraryAuthorization(r.Context(), item); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot record authorization"})
return
}
writeJSON(w, http.StatusCreated, map[string]any{"authorization": item})
}
func libraryAuthorizationList(w http.ResponseWriter, r *http.Request, config Config) {
scope, err := scopeFromQuery(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
store, ok := libraryAuthorizationStoreOf(config)
if !ok {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"available": false, "reason": "授权存储不可用", "authorizations": []any{}})
return
}
items, err := store.LibraryAuthorizations(r.Context(), scope, 50)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot read authorizations"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"available": true, "authorizations": items})
}
func libraryAuthorizationRevoke(w http.ResponseWriter, r *http.Request, config Config) {
identity, ok := sessionFrom(r.Context())
if !ok || roleRank(identity.Role) < roleRank(roleAdmin) {
writeJSON(w, http.StatusForbidden, map[string]any{"error": "撤销生产发布授权仅管理员可执行"})
return
}
store, ok := libraryAuthorizationStoreOf(config)
if !ok {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "授权存储不可用"})
return
}
id := strings.TrimSpace(r.PathValue("id"))
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "authorization id required"})
return
}
if err := store.RevokeLibraryAuthorization(r.Context(), id, time.Now().UTC()); err != nil {
writeJSON(w, http.StatusConflict, map[string]any{"error": "授权不存在或已撤销"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"revoked": id})
}