mirror of
https://github.com/renorris/openfsd
synced 2026-08-13 04:55:42 +08:00
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:
@@ -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:
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user