diff --git a/internal/web/README.md b/internal/web/README.md index cf8786f..ce0fb9a 100644 --- a/internal/web/README.md +++ b/internal/web/README.md @@ -15,7 +15,7 @@ JSON under `/api/v1` for external tools and map polling. First-party UI is a pro | Users (directory) | `GET /usereditor[?q&rating&sort&dir&page&cid&new&flash]`, `POST /usereditor/create`, `POST /usereditor/update` | **Supervisor+**; create + name/password + ratings (network ceiling ≤ actor; full pilot scale). CSRF; URL-owned filters; `dir_*` on POST for PRG | | Config editor | `GET/POST /configeditor`, `POST /configeditor/create-token`, `POST /configeditor/reset-secret` | Administrator; CSRF on mutations | | Sweatbox | `GET /sweatbox`, form POSTs under `/sweatbox/*` | **Instructor1+**; CSRF on mutations; proxies FSD service HTTP | -| Airport editor | `GET /airport-editor`, `POST /airport-editor/download-apt`, `POST /airport-editor/download-air` | Administrator; CSRF on download; **echo-only** (no disk/DB persistence of `.apt`/`.air`) | +| Airport editor | `GET /airport-editor`, `POST /airport-editor/download-apt`, `POST /airport-editor/download-air` | **Instructor1+**; CSRF on download; **echo-only** (no disk/DB persistence of `.apt`/`.air`). Validate API: `POST /api/v1/editor/validate-*` also I1+ | JSON under `/api/v1` remains for external consumers and map polling. Session dual-accept mutations work with **cookie + CSRF only** (no `Authorization` header required). @@ -68,9 +68,9 @@ Authorization: Bearer ## Network Ratings The API enforces role-based access control using `NetworkRating` values defined in `pkg/protocol`. Key thresholds: -- **Instructor1–3 (8–10)**: Sweatbox instructor UI + JSON proxies. Cannot open the Users directory. +- **Instructor1–3 (8–10)**: Sweatbox instructor UI + JSON proxies; airport editor + validate API. Cannot open the Users directory. - **Supervisor (11)**: User editor (create, name, password, ratings); kick active connections. Network rating assignments capped at own rating; pilot ratings use the full official scale. -- **Administrator (12)**: Server configuration, JWT secret reset, API tokens, airport editor. +- **Administrator (12)**: Server configuration, JWT secret reset, API tokens. - **Suspended (0) / Inactive (-1)**: Cannot log in to the web UI or obtain FSD JWTs; existing session cookies are rejected on revalidation. --- diff --git a/internal/web/api_editor.go b/internal/web/api_editor.go index bd2974f..4446e7c 100644 --- a/internal/web/api_editor.go +++ b/internal/web/api_editor.go @@ -31,9 +31,9 @@ type editorValidateAIRData struct { AircraftCount int `json:"aircraft_count"` } -// setupEditorAPIRoutes mounts Admin-only APT/AIR validate endpoints under /api/v1/editor. +// setupEditorAPIRoutes mounts Instructor1+ APT/AIR validate endpoints under /api/v1/editor. // Dual-accept Bearer | session cookie; CSRF when cookie-authenticated. -// Authz is inline Admin → 403 JSON (never HTML redirect). +// Authz is inline I1+ → 403 JSON (never HTML redirect). func (s *Server) setupEditorAPIRoutes(parent *gin.RouterGroup) { g := parent.Group("/editor") g.Use(s.jwtBearerMiddleware, s.csrfIfCookieSession) @@ -43,11 +43,11 @@ func (s *Server) setupEditorAPIRoutes(parent *gin.RouterGroup) { // handleAPIValidateAPT POST /api/v1/editor/validate-apt // -// Stateless ParseAPT of JSON {"text":"…"}. Admin only; 403 JSON when rating too low. +// Stateless ParseAPT of JSON {"text":"…"}. Instructor1+ only; 403 JSON when rating too low. // Soft validation errors are returned in data.errors (HTTP 200); never returns geometry. func (s *Server) handleAPIValidateAPT(c *gin.Context) { claims := getJwtContext(c) - if claims == nil || claims.NetworkRating < protocol.NetworkRatingAdministator { + if claims == nil || claims.NetworkRating < protocol.NetworkRatingInstructor1 { writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) return } @@ -80,11 +80,11 @@ func (s *Server) handleAPIValidateAPT(c *gin.Context) { // handleAPIValidateAIR POST /api/v1/editor/validate-air // -// Stateless ParseAIR of JSON {"text":"…"}. Admin only; 403 JSON when rating too low. +// Stateless ParseAIR of JSON {"text":"…"}. Instructor1+ only; 403 JSON when rating too low. // Soft validation errors are returned in data.errors (HTTP 200); never returns aircraft rows. func (s *Server) handleAPIValidateAIR(c *gin.Context) { claims := getJwtContext(c) - if claims == nil || claims.NetworkRating < protocol.NetworkRatingAdministator { + if claims == nil || claims.NetworkRating < protocol.NetworkRatingInstructor1 { writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) return } diff --git a/internal/web/api_editor_test.go b/internal/web/api_editor_test.go index 409afe7..9af37e8 100644 --- a/internal/web/api_editor_test.go +++ b/internal/web/api_editor_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/renorris/openfsd/internal/db" "github.com/renorris/openfsd/pkg/protocol" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -61,6 +62,26 @@ func TestAPIValidateAIRForbiddenForObserver(t *testing.T) { assert.Equal(t, "forbidden", *res.Err) } +func TestAPIValidateAPTAllowedForInstructor(t *testing.T) { + env := setupTestAPI(t) + i1Pass := "i1pass123" + i1 := &db.User{ + Password: i1Pass, + FirstName: strPtr("Inst"), + LastName: strPtr("One"), + NetworkRating: int(protocol.NetworkRatingInstructor1), + } + require.NoError(t, env.server.dbRepo.UserRepo.CreateUser(i1)) + + access, _ := env.login(t, i1.CID, i1Pass) + w := env.doJSON(t, http.MethodPost, "/api/v1/editor/validate-apt", map[string]any{ + "text": "icao=KBTV\n", + }, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + res := decodeAPIV1(t, w) + require.Nil(t, res.Err) +} + func TestAPIValidateAPTAdminOKFixture(t *testing.T) { env := setupTestAPI(t) access, _ := env.login(t, env.admin.CID, env.adminPass) diff --git a/internal/web/pagemodel.go b/internal/web/pagemodel.go index 7318ff1..7062fe9 100644 --- a/internal/web/pagemodel.go +++ b/internal/web/pagemodel.go @@ -23,7 +23,9 @@ type pageUser struct { CanFullMutateUsers bool // CanAccessSweatbox: Instructor1+ — sweatbox HTML + PE JSON. CanAccessSweatbox bool - // CanEditConfig: Administrator — config + airport editor. + // CanAccessAirportEditor: Instructor1+ — airport .apt/.air editor + validate API. + CanAccessAirportEditor bool + // CanEditConfig: Administrator — config editor. CanEditConfig bool } @@ -230,7 +232,7 @@ type sweatboxPage struct { Aircraft []sweatboxAircraftRow } -// airportEditorPage is the Administrator airport .apt/.air editor MPA model. +// airportEditorPage is the Instructor1+ airport .apt/.air editor MPA model. // No durable server state — document lives in the browser; download is echo-only. type airportEditorPage struct { basePage @@ -248,16 +250,17 @@ func pageUserFromClaims(claims *auth.CustomClaims) *pageUser { } rating := int(claims.NetworkRating) return &pageUser{ - CID: claims.CID, - DisplayName: display, - FirstName: claims.FirstName, - LastName: claims.LastName, - NetworkRating: rating, - NetworkRatingLabel: networkRatingLabel(rating), - CanEditUsers: canAccessUserEditor(claims.NetworkRating), - CanFullMutateUsers: canFullMutateUsers(claims.NetworkRating), - CanAccessSweatbox: canAccessSweatbox(claims.NetworkRating), - CanEditConfig: claims.NetworkRating >= protocol.NetworkRatingAdministator, + CID: claims.CID, + DisplayName: display, + FirstName: claims.FirstName, + LastName: claims.LastName, + NetworkRating: rating, + NetworkRatingLabel: networkRatingLabel(rating), + CanEditUsers: canAccessUserEditor(claims.NetworkRating), + CanFullMutateUsers: canFullMutateUsers(claims.NetworkRating), + CanAccessSweatbox: canAccessSweatbox(claims.NetworkRating), + CanAccessAirportEditor: canAccessAirportEditor(claims.NetworkRating), + CanEditConfig: claims.NetworkRating >= protocol.NetworkRatingAdministator, } } diff --git a/internal/web/pages_airport_editor_test.go b/internal/web/pages_airport_editor_test.go index 9bd7d4e..e4dc1aa 100644 --- a/internal/web/pages_airport_editor_test.go +++ b/internal/web/pages_airport_editor_test.go @@ -38,17 +38,14 @@ func TestAirportEditorObserverRedirect(t *testing.T) { } } -func TestAirportEditorSupervisorRedirect(t *testing.T) { +func TestAirportEditorInstructorShell(t *testing.T) { ts := newTestServer(t) - sup := createTestUser(t, ts, "pw", int(protocol.NetworkRatingSupervisor)) - cookies := formLogin(t, ts, sup.CID, "pw") + inst := createTestUser(t, ts, "inst-pass", int(protocol.NetworkRatingInstructor1)) + cookies := formLogin(t, ts, inst.CID, "inst-pass") w, _ := authedGET(t, ts, "/airport-editor", cookies) - if w.Code != http.StatusSeeOther { - t.Fatalf("status %d want 303", w.Code) - } - if loc := w.Header().Get("Location"); loc != "/dashboard" { - t.Fatalf("Location=%q want /dashboard", loc) + if w.Code != http.StatusOK { + t.Fatalf("I1 status %d want 200 body %s", w.Code, w.Body.String()) } } @@ -118,7 +115,7 @@ func TestAirportEditorAdminShell(t *testing.T) { } } -func TestAirportEditorNavOnlyForAdmin(t *testing.T) { +func TestAirportEditorNavForInstructorNotObserver(t *testing.T) { ts := newTestServer(t) obs := createTestUser(t, ts, "pw", int(protocol.NetworkRatingObserver)) cookies := formLogin(t, ts, obs.CID, "pw") @@ -128,12 +125,12 @@ func TestAirportEditorNavOnlyForAdmin(t *testing.T) { t.Fatal("observer must not see Airport Editor nav") } - admin := createTestUser(t, ts, "admin-pass", int(protocol.NetworkRatingAdministator)) - cookies = formLogin(t, ts, admin.CID, "admin-pass") + inst := createTestUser(t, ts, "inst-pass", int(protocol.NetworkRatingInstructor1)) + cookies = formLogin(t, ts, inst.CID, "inst-pass") w, _ = authedGET(t, ts, "/dashboard", cookies) body = w.Body.String() if !strings.Contains(body, `href="/airport-editor"`) { - t.Fatal("admin dashboard should link to airport editor") + t.Fatal("I1 dashboard should link to airport editor") } } diff --git a/internal/web/routes.go b/internal/web/routes.go index 5d2c490..bda746f 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -130,7 +130,7 @@ func (s *Server) setupFrontendRoutes(parent *gin.RouterGroup) { userAdmin.POST("/usereditor/create", s.handleFrontendUserCreate) userAdmin.POST("/usereditor/update", s.handleFrontendUserUpdate) - // Sweatbox instructor UI: Instructor1+ (HTML forms; proxies FSD /sweatbox/*). + // Instructor1+ tools: sweatbox + airport editor (HTML forms; CSRF on mutations). instructor := authed.Group("") instructor.Use(s.requireMinRatingHTML(protocol.NetworkRatingInstructor1)) instructor.GET("/sweatbox", s.handleFrontendSweatbox) @@ -143,16 +143,16 @@ func (s *Server) setupFrontendRoutes(parent *gin.RouterGroup) { instructor.POST("/sweatbox/delete", s.handleFrontendSweatboxDelete) instructor.POST("/sweatbox/delete-all", s.handleFrontendSweatboxDeleteAll) - // Admin config + airport editor: form POST mutations with CSRF; no JS required. + // Airport editor: HTML shell + echo-download (no disk/DB persistence). + instructor.GET("/airport-editor", s.handleFrontendAirportEditor) + instructor.POST("/airport-editor/download-apt", s.handleFrontendAirportEditorDownloadAPT) + instructor.POST("/airport-editor/download-air", s.handleFrontendAirportEditorDownloadAIR) + + // Admin config only: form POST mutations with CSRF; no JS required. admin := authed.Group("") admin.Use(s.requireMinRatingHTML(protocol.NetworkRatingAdministator)) admin.GET("/configeditor", s.handleFrontendConfigEditor) admin.POST("/configeditor", s.handleFrontendConfigUpdate) admin.POST("/configeditor/reset-secret", s.handleFrontendConfigResetSecret) admin.POST("/configeditor/create-token", s.handleFrontendConfigCreateToken) - - // Airport editor: HTML shell + no-JS echo-download (no disk/DB persistence). - admin.GET("/airport-editor", s.handleFrontendAirportEditor) - admin.POST("/airport-editor/download-apt", s.handleFrontendAirportEditorDownloadAPT) - admin.POST("/airport-editor/download-air", s.handleFrontendAirportEditorDownloadAIR) } diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 3dd7421..d2335b8 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -40,11 +40,13 @@ {{ if .User.CanAccessSweatbox }}
  • Sweatbox
  • {{ end }} - {{ if .User.CanEditConfig }} -
  • Config
  • + {{ if .User.CanAccessAirportEditor }}
  • Airport Editor
  • {{ end }} - {{ if and (not .User.CanEditUsers) (not .User.CanAccessSweatbox) (not .User.CanEditConfig) }} + {{ if .User.CanEditConfig }} +
  • Config
  • + {{ end }} + {{ if and (not .User.CanEditUsers) (not .User.CanAccessSweatbox) (not .User.CanAccessAirportEditor) (not .User.CanEditConfig) }}
  • No elevated tools for your rating. Use Account to manage your password.
  • {{ end }} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 9fc55ab..f6db83b 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -53,9 +53,11 @@ {{ if .User.CanAccessSweatbox }} Sweatbox {{ end }} + {{ if .User.CanAccessAirportEditor }} + Airport Editor + {{ end }} {{ if .User.CanEditConfig }} Config - Airport Editor {{ end }}
    diff --git a/internal/web/templates/sweatbox_manual.html b/internal/web/templates/sweatbox_manual.html index cbbc2b2..f50eaa6 100644 --- a/internal/web/templates/sweatbox_manual.html +++ b/internal/web/templates/sweatbox_manual.html @@ -60,7 +60,7 @@ Role - Instructor1+ network rating (I1, I2, I3, SUP, ADM). Config and Airport Editor remain Administrator-only. + Instructor1+ network rating (I1, I2, I3, SUP, ADM). Airport Editor is also I1+. Config remains Administrator-only. FSD process diff --git a/internal/web/user_authz.go b/internal/web/user_authz.go index b66f1a8..6e95e0b 100644 --- a/internal/web/user_authz.go +++ b/internal/web/user_authz.go @@ -10,8 +10,9 @@ import ( // // - Supervisor+: user editor directory, create, rating + full profile mutation. // Full profile mutation still cannot target users with a higher network rating. -// - Instructor1+: sweatbox control plane (HTML + PE JSON). -// - Administrator: config + airport editor (unchanged). +// - Instructor1+: sweatbox control plane (HTML + PE JSON) and airport editor +// (HTML + validate API). +// - Administrator: config editor (unchanged). func canAccessUserEditor(r protocol.NetworkRating) bool { return r >= protocol.NetworkRatingSupervisor @@ -37,6 +38,10 @@ func canAccessSweatbox(r protocol.NetworkRating) bool { return r >= protocol.NetworkRatingInstructor1 } +func canAccessAirportEditor(r protocol.NetworkRating) bool { + return r >= protocol.NetworkRatingInstructor1 +} + func pilotRatingLabel(v int) string { // "PPL — Private Pilot License" style for selects / tooltips. short := protocol.PilotRatingShort(v)