web: allow Instructor1+ access to airport editor

Move airport-editor HTML routes and validate API from ADM to I1+, matching
sweatbox. Config editor stays Administrator-only. Nav/dashboard use
CanAccessAirportEditor; PE tests cover I1 shell, nav, and validate-apt.
This commit is contained in:
Reese Norris
2026-07-27 18:53:44 -04:00
parent 55cfae589e
commit 895dbd8fc8
10 changed files with 77 additions and 47 deletions

View File

@@ -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 <access_token>
## Network Ratings
The API enforces role-based access control using `NetworkRating` values defined in `pkg/protocol`. Key thresholds:
- **Instructor13 (810)**: Sweatbox instructor UI + JSON proxies. Cannot open the Users directory.
- **Instructor13 (810)**: 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.
---

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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,
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -40,11 +40,13 @@
{{ if .User.CanAccessSweatbox }}
<li><a href="/sweatbox" class="btn btn-outline-secondary">Sweatbox</a></li>
{{ end }}
{{ if .User.CanEditConfig }}
<li><a href="/configeditor" class="btn btn-outline-secondary">Config</a></li>
{{ if .User.CanAccessAirportEditor }}
<li><a href="/airport-editor" class="btn btn-outline-secondary">Airport Editor</a></li>
{{ end }}
{{ if and (not .User.CanEditUsers) (not .User.CanAccessSweatbox) (not .User.CanEditConfig) }}
{{ if .User.CanEditConfig }}
<li><a href="/configeditor" class="btn btn-outline-secondary">Config</a></li>
{{ end }}
{{ if and (not .User.CanEditUsers) (not .User.CanAccessSweatbox) (not .User.CanAccessAirportEditor) (not .User.CanEditConfig) }}
<li class="text-muted small">No elevated tools for your rating. Use Account to manage your password.</li>
{{ end }}
</ul>

View File

@@ -53,9 +53,11 @@
{{ if .User.CanAccessSweatbox }}
<a class="btn btn-sm btn-outline-secondary" href="/sweatbox">Sweatbox</a>
{{ end }}
{{ if .User.CanAccessAirportEditor }}
<a class="btn btn-sm btn-outline-secondary" href="/airport-editor">Airport Editor</a>
{{ end }}
{{ if .User.CanEditConfig }}
<a class="btn btn-sm btn-outline-secondary" href="/configeditor">Config</a>
<a class="btn btn-sm btn-outline-secondary" href="/airport-editor">Airport Editor</a>
{{ end }}
<form method="post" action="/logout" class="d-inline m-0">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">

View File

@@ -60,7 +60,7 @@
<tbody>
<tr>
<td>Role</td>
<td>Instructor1+ network rating (I1, I2, I3, SUP, ADM). Config and Airport Editor remain Administrator-only.</td>
<td>Instructor1+ network rating (I1, I2, I3, SUP, ADM). Airport Editor is also I1+. Config remains Administrator-only.</td>
</tr>
<tr>
<td>FSD process</td>

View File

@@ -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)