diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 310dd19..627d325 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -167,6 +167,26 @@ components: type: string description: Correlates the response with server logs (withRequestID middleware). + UpdateWindow: + type: object + description: > + The SysAdmin-set auto-update maintenance window (internal/api/handlers_updates.go + updateWindow). An absolute [start,end) interval during which Felis may apply a + Scheduled component's update to itself; both ends null means unset (no apply is + ever opened). Keys are always present; their values are null when unset. + required: [start, end] + properties: + start: + type: string + format: date-time + nullable: true + description: Window start (RFC3339, inclusive), or null when unset. + end: + type: string + format: date-time + nullable: true + description: Window end (RFC3339, exclusive), or null when unset. + PasskeyCredential: type: object description: > @@ -1464,6 +1484,67 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + /api/v1/updates/window: + get: + tags: [admin-updates] + operationId: getUpdateWindow + summary: Read the SysAdmin-set auto-update maintenance window (admin). + description: >- + The single platform-wide maintenance window during which Felis may apply a + Scheduled component's update to itself (decision core internal/updates). An + unset window — never set, or explicitly cleared — reads back as + {start:null,end:null}. API+persistence only: nothing consumes the window + until the INTEGRATION runner and executors are wired, so setting it changes + no behavior yet. + x-felis-face: [external] + x-felis-tier: admin + security: [{ accessJWT: [] }] + responses: + '200': + description: The current maintenance window (both ends null when unset). + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateWindow' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + put: + tags: [admin-updates] + operationId: setUpdateWindow + summary: Set or clear the SysAdmin auto-update maintenance window (admin). + description: >- + Persist the maintenance window as an absolute [start,end) interval. Both + ends must be set with end strictly after start, or both null to clear the + window to unset. A half-set (exactly one end) or inverted/empty (end not + after start) body is rejected 400, mirroring the decision core's fail-closed + Window so a malformed schedule can never be stored. No forced auto-update: + setting a window only permits an apply inside it; outside, a Scheduled + component degrades to notify. + x-felis-face: [external] + x-felis-tier: admin + security: [{ accessJWT: [] }] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateWindow' + responses: + '200': + description: The stored maintenance window (echoed back). + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateWindow' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + /api/v1/fleet: get: tags: [admin-servers] diff --git a/internal/api/api.go b/internal/api/api.go index 6bdbac4..ee6efb6 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -332,6 +332,12 @@ func (a *API) externalAPIRoutes() []apiRoute { {Method: "GET", Pattern: "/api/v1/submissions", Admin: true, h: a.handleListSubmissions}, {Method: "POST", Pattern: "/api/v1/submissions/{id}/approve", Admin: true, h: a.handleApproveSubmission}, {Method: "POST", Pattern: "/api/v1/submissions/{id}/reject", Admin: true, h: a.handleRejectSubmission}, + // Auto-update maintenance window (spec §B; decision core internal/updates). + // Admin-tier: it governs whether Felis may apply an update to itself, so setting + // it requires the admin Zero-Trust path, not a mere session. API+persistence + // only — the runner/executors that consume the window are still INTEGRATION-ONLY. + {Method: "GET", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleGetUpdateWindow}, + {Method: "PUT", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleSetUpdateWindow}, } } diff --git a/internal/api/handlers_updates.go b/internal/api/handlers_updates.go new file mode 100644 index 0000000..dbc35ed --- /dev/null +++ b/internal/api/handlers_updates.go @@ -0,0 +1,118 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "time" +) + +// SysAdmin-set maintenance window for the auto-update subsystem (task #38; the +// decision core is internal/updates). The user red line is "不要强制自动更新": Felis +// never force-upgrades. A PolicyScheduled component may only be applied by Felis +// while now falls inside a window a SysAdmin explicitly set here; outside it, the +// same component degrades to notify-only. These two admin routes are how that +// window is read and set. +// +// SINGLE GLOBAL WINDOW (deliberate). internal/updates models the window PER +// Component (Component.Window), but this endpoint stores ONE platform-wide window. +// The task scopes "the SysAdmin-set update Window" as a single maintenance slot, +// and the core's own comment defers recurrence to the caller — so the (not-yet- +// built, INTEGRATION-ONLY) `felis update` runner reads this one window and fans it +// out to every Scheduled+manageable component when it assembles its []Component. +// A future need for per-component windows would layer keys on top; this is the +// platform default. +// +// This slice is API + PERSISTENCE ONLY. Nothing consumes the stored window yet: +// the runner, the ReleaseSource/Notifier/Applier executors and the scheduler +// CronJob are all still INTEGRATION-ONLY (task #38 remainder). Setting a window +// today changes no behavior until those land — it is the durable input they will +// read. The Panel UI that drives these routes is out of scope (hands-off-frontend). + +// updateWindowKey is the platform_settings key holding the maintenance window as +// JSON {"start","end"} (RFC3339, or null when unset). It reuses the generic +// settings KV rather than a dedicated table: the window is a single small tuple a +// human edits rarely, exactly the shape platform_settings exists for (like +// LocalAuthEnabledKey). +const updateWindowKey = "update_window" + +// updateWindow is the wire + storage shape of the maintenance window. Pointer times +// make "unset" (null) distinguishable from a real instant on both decode and +// encode: an absent/null start or end marshals back to JSON null, and the core's +// zero-Window semantics ("unset ⇒ Contains is always false") map onto a stored +// {null,null}. The two ends are an absolute [start,end) interval — the SysAdmin +// picks a concrete next window; recurrence is the runner's concern, not this store's. +type updateWindow struct { + Start *time.Time `json:"start"` + End *time.Time `json:"end"` +} + +// handleGetUpdateWindow returns the current maintenance window (admin-tier). An +// unset window — never written, or explicitly cleared to {null,null} — both read +// back as {start:null,end:null}, so the caller has one shape to handle. Only a +// genuinely missing key is treated as unset (ErrNotFound → 200 nulls); any OTHER +// store error is a real failure and 500s rather than masquerading as "no window" +// (a fail-open read on a DB blip would silently drop a scheduled window). +func (a *API) handleGetUpdateWindow(w http.ResponseWriter, r *http.Request) { + raw, err := a.Repo.GetSetting(r.Context(), updateWindowKey) + switch { + case errors.Is(err, ErrNotFound): + writeJSON(w, http.StatusOK, updateWindow{}) + return + case err != nil: + writeError(w, r, err) + return + } + var win updateWindow + if err := json.Unmarshal(raw, &win); err != nil { + writeError(w, r, err) + return + } + writeJSON(w, http.StatusOK, win) +} + +// handleSetUpdateWindow persists the maintenance window (admin-tier). Validation +// mirrors the decision core's fail-closed Window: a window is either fully set +// (both ends, end strictly after start) or fully cleared (both null) — never +// half-set, never inverted. A half-set body (exactly one end) or an inverted / +// empty interval (end not after start) is a 400, so a malformed schedule can never +// be stored and later fail open. Both-null clears the window to unset. +// +// No "not in the past" check is enforced: a window whose end has passed is harmless +// — the core's Window.Contains returns false once now ≥ end — so a stale window +// simply never re-opens an apply rather than being an error to store. +func (a *API) handleSetUpdateWindow(w http.ResponseWriter, r *http.Request) { + if err := requireJSONContentType(r); err != nil { + writeError(w, r, err) + return + } + var body updateWindow + if err := decodeJSON(w, r, &body); err != nil { + writeError(w, r, err) + return + } + switch { + case body.Start == nil && body.End == nil: + // clearing to unset — fall through to persist {null,null} + case body.Start == nil || body.End == nil: + writeError(w, r, newError(http.StatusBadRequest, "bad_request", + "start and end must both be set or both be null")) + return + case !body.End.After(*body.Start): + writeError(w, r, newError(http.StatusBadRequest, "bad_request", + "end must be after start")) + return + } + + value, err := json.Marshal(body) + if err != nil { + writeError(w, r, err) + return + } + if err := a.Repo.SetSetting(r.Context(), updateWindowKey, value); err != nil { + writeError(w, r, err) + return + } + a.audit(r, principalFromContext(r.Context()).Email, "updates.window_set", "") + writeJSON(w, http.StatusOK, body) +} diff --git a/internal/api/handlers_updates_test.go b/internal/api/handlers_updates_test.go new file mode 100644 index 0000000..5893e3d --- /dev/null +++ b/internal/api/handlers_updates_test.go @@ -0,0 +1,187 @@ +package api + +import ( + "encoding/json" + "net/http" + "testing" +) + +// Auto-update maintenance-window admin API tests (task #38; decision core +// internal/updates). The load-bearing cases: an unset window and an explicitly +// cleared window both read back as {null,null} (two paths, one shape); a valid +// window round-trips through persistence; a half-set or inverted window is +// rejected fail-closed and never stored; and — the tier boundary — the routes are +// admin-only, gating precisely on IsAdmin() = role==admin AND the admin-access +// path, so neither a role=user nor an admin off the operator host can touch them. + +// seedUpdatesAPI returns an API whose external face authenticates every request as +// a full admin (role=admin AND ViaAdminAccess), so the admin-tier routes are +// reachable and the tests exercise the handler logic. Gating tests override +// api.External to vary the principal. +func seedUpdatesAPI(t *testing.T) (*API, *fakeRepo) { + t.Helper() + repo := newFakeRepo() + api := newTestAPI(repo, newFakeCluster()) + api.External = staticExternal{p: &Principal{ + UserID: "admin1", Email: "admin@" + testRoot, Role: "admin", ViaAdminAccess: true, + }} + return api, repo +} + +// decodeWindow reads the {start,end} body, each a string or nil. +func decodeWindow(t *testing.T, w interface{ Bytes() []byte }) (start, end any) { + t.Helper() + var body map[string]any + if err := json.Unmarshal(w.Bytes(), &body); err != nil { + t.Fatalf("window body not JSON: %v", err) + } + if _, ok := body["start"]; !ok { + t.Fatalf("window body missing start key: %s", string(w.Bytes())) + } + if _, ok := body["end"]; !ok { + t.Fatalf("window body missing end key: %s", string(w.Bytes())) + } + return body["start"], body["end"] +} + +// TestUpdateWindowUnsetReturnsNulls: a never-set window reads back as {null,null} +// (ErrNotFound → 200 nulls), so the client has one shape whether or not a window +// was ever configured. +func TestUpdateWindowUnsetReturnsNulls(t *testing.T) { + api, _ := seedUpdatesAPI(t) + w := do(api.ExternalHandler(), "GET", "/api/v1/updates/window", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + if start, end := decodeWindow(t, w.Body); start != nil || end != nil { + t.Fatalf("unset window = {%v,%v}, want {null,null}", start, end) + } +} + +// TestUpdateWindowSetThenGet: a valid window is persisted and read back verbatim. +func TestUpdateWindowSetThenGet(t *testing.T) { + api, repo := seedUpdatesAPI(t) + const startISO, endISO = "2026-08-01T02:00:00Z", "2026-08-01T04:00:00Z" + body := `{"start":"` + startISO + `","end":"` + endISO + `"}` + + put := do(api.ExternalHandler(), "PUT", "/api/v1/updates/window", body, jsonHeader) + if put.Code != http.StatusOK { + t.Fatalf("PUT code = %d, want 200 (%s)", put.Code, put.Body.String()) + } + if start, end := decodeWindow(t, put.Body); start != startISO || end != endISO { + t.Fatalf("PUT echo = {%v,%v}, want {%s,%s}", start, end, startISO, endISO) + } + // It was actually persisted (not merely echoed). + if _, ok := repo.settings[updateWindowKey]; !ok { + t.Fatalf("window was not written to platform_settings[%q]", updateWindowKey) + } + + get := do(api.ExternalHandler(), "GET", "/api/v1/updates/window", "", nil) + if get.Code != http.StatusOK { + t.Fatalf("GET code = %d, want 200 (%s)", get.Code, get.Body.String()) + } + if start, end := decodeWindow(t, get.Body); start != startISO || end != endISO { + t.Fatalf("GET after set = {%v,%v}, want {%s,%s}", start, end, startISO, endISO) + } +} + +// TestUpdateWindowClearRoundtrips: PUT {null,null} clears a previously set window, +// and the cleared window reads back as {null,null} — the SAME shape as never-set, +// exercising the second code path that yields nulls. +func TestUpdateWindowClearRoundtrips(t *testing.T) { + api, _ := seedUpdatesAPI(t) + set := do(api.ExternalHandler(), "PUT", "/api/v1/updates/window", + `{"start":"2026-08-01T02:00:00Z","end":"2026-08-01T04:00:00Z"}`, jsonHeader) + if set.Code != http.StatusOK { + t.Fatalf("initial set code = %d, want 200 (%s)", set.Code, set.Body.String()) + } + + clear := do(api.ExternalHandler(), "PUT", "/api/v1/updates/window", `{"start":null,"end":null}`, jsonHeader) + if clear.Code != http.StatusOK { + t.Fatalf("clear code = %d, want 200 (%s)", clear.Code, clear.Body.String()) + } + if start, end := decodeWindow(t, clear.Body); start != nil || end != nil { + t.Fatalf("clear echo = {%v,%v}, want {null,null}", start, end) + } + + get := do(api.ExternalHandler(), "GET", "/api/v1/updates/window", "", nil) + if start, end := decodeWindow(t, get.Body); start != nil || end != nil { + t.Fatalf("GET after clear = {%v,%v}, want {null,null}", start, end) + } +} + +// TestUpdateWindowRejectsMalformed: a half-set (exactly one end) or inverted/empty +// (end not after start) window is 400 and is NEVER written — the store can only +// ever hold a fully-valid or a fully-cleared window, mirroring the core's +// fail-closed Window. +func TestUpdateWindowRejectsMalformed(t *testing.T) { + for _, tc := range []struct{ name, body string }{ + {"start without end", `{"start":"2026-08-01T02:00:00Z","end":null}`}, + {"end without start", `{"start":null,"end":"2026-08-01T04:00:00Z"}`}, + {"inverted", `{"start":"2026-08-01T04:00:00Z","end":"2026-08-01T02:00:00Z"}`}, + {"empty interval", `{"start":"2026-08-01T02:00:00Z","end":"2026-08-01T02:00:00Z"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + api, repo := seedUpdatesAPI(t) + w := do(api.ExternalHandler(), "PUT", "/api/v1/updates/window", tc.body, jsonHeader) + if w.Code != http.StatusBadRequest { + t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String()) + } + if code := decodeErr(t, w); code != "bad_request" { + t.Fatalf("error code = %q, want bad_request", code) + } + if _, ok := repo.settings[updateWindowKey]; ok { + t.Fatal("a rejected window must not be persisted") + } + }) + } +} + +// TestUpdateWindowContentTypeGuard pins the cross-site-forgery guard on the mutating +// PUT: a body an HTML form could emit is rejected 415 before any write. +func TestUpdateWindowContentTypeGuard(t *testing.T) { + api, repo := seedUpdatesAPI(t) + w := do(api.ExternalHandler(), "PUT", "/api/v1/updates/window", + `{"start":"2026-08-01T02:00:00Z","end":"2026-08-01T04:00:00Z"}`, + map[string]string{"Content-Type": "application/x-www-form-urlencoded"}) + if w.Code != http.StatusUnsupportedMediaType { + t.Fatalf("code = %d, want 415 (%s)", w.Code, w.Body.String()) + } + if _, ok := repo.settings[updateWindowKey]; ok { + t.Fatal("a content-type-rejected PUT must not persist") + } +} + +// TestUpdateWindowAdminGating proves the tier boundary discriminates on IsAdmin() +// precisely — role==admin AND the admin-access path — not on accident. A full admin +// reads 200; an admin who did NOT arrive via admin access, and a role=user player, +// are both 403 on both the GET and the mutating PUT. +func TestUpdateWindowAdminGating(t *testing.T) { + for _, tc := range []struct { + name string + p *Principal + want int + }{ + {"admin via admin-access", &Principal{UserID: "a1", Role: "admin", ViaAdminAccess: true}, http.StatusOK}, + {"admin off operator host", &Principal{UserID: "a1", Role: "admin", ViaAdminAccess: false}, http.StatusForbidden}, + {"role=user player", &Principal{UserID: "u1", Role: "user", ViaAdminAccess: false}, http.StatusForbidden}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := newFakeRepo() + api := newTestAPI(repo, newFakeCluster()) + api.External = staticExternal{p: tc.p} + + get := do(api.ExternalHandler(), "GET", "/api/v1/updates/window", "", nil) + if get.Code != tc.want { + t.Fatalf("GET code = %d, want %d (%s)", get.Code, tc.want, get.Body.String()) + } + // A valid PUT by a full admin is 200; a non-admin is 403 (adminOnly rejects + // before the handler), so the wanted PUT code is the same as the GET's. + put := do(api.ExternalHandler(), "PUT", "/api/v1/updates/window", + `{"start":"2026-08-01T02:00:00Z","end":"2026-08-01T04:00:00Z"}`, jsonHeader) + if put.Code != tc.want { + t.Fatalf("PUT code = %d, want %d (%s)", put.Code, tc.want, put.Body.String()) + } + }) + } +}