diff --git a/internal/ui/handlers_accounts.go b/internal/ui/handlers_accounts.go index 20a1637..e90ece3 100644 --- a/internal/ui/handlers_accounts.go +++ b/internal/ui/handlers_accounts.go @@ -39,7 +39,7 @@ func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) { } u.render(w, "accounts", AccountsData{ - PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, + PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)}, Accounts: accounts, }) } @@ -183,7 +183,7 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) { } u.render(w, "account_detail", AccountDetailData{ - PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, + PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)}, Account: acct, Roles: roles, AllRoles: knownRoles, @@ -790,7 +790,7 @@ func (u *UIServer) handlePGCredsList(w http.ResponseWriter, r *http.Request) { } u.render(w, "pgcreds", PGCredsData{ - PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, + PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)}, Creds: creds, UncredentialedAccounts: uncredentialed, CredGrants: credGrants, diff --git a/internal/ui/handlers_audit.go b/internal/ui/handlers_audit.go index f74a342..617076e 100644 --- a/internal/ui/handlers_audit.go +++ b/internal/ui/handlers_audit.go @@ -86,7 +86,7 @@ func (u *UIServer) handleAuditDetail(w http.ResponseWriter, r *http.Request) { } u.render(w, "audit_detail", AuditDetailData{ - PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, + PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)}, Event: event, }) } @@ -116,7 +116,7 @@ func (u *UIServer) buildAuditData(r *http.Request, page int, csrfToken string) ( } return AuditData{ - PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, + PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)}, Events: events, EventTypes: auditEventTypes, FilterType: filterType, diff --git a/internal/ui/handlers_auth.go b/internal/ui/handlers_auth.go index 9855d6c..06610a0 100644 --- a/internal/ui/handlers_auth.go +++ b/internal/ui/handlers_auth.go @@ -283,6 +283,7 @@ func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) { PageData: PageData{ CSRFToken: csrfToken, ActorName: u.actorName(r), + IsAdmin: isAdmin(r), }, }) } @@ -395,6 +396,7 @@ func (u *UIServer) handleSelfChangePassword(w http.ResponseWriter, r *http.Reque PageData: PageData{ CSRFToken: csrfToken, ActorName: u.actorName(r), + IsAdmin: isAdmin(r), Flash: "Password updated successfully. Other active sessions have been revoked.", }, }) diff --git a/internal/ui/handlers_dashboard.go b/internal/ui/handlers_dashboard.go index 00c05bb..db1472e 100644 --- a/internal/ui/handlers_dashboard.go +++ b/internal/ui/handlers_dashboard.go @@ -17,15 +17,13 @@ func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) { return } - claims := claimsFromContext(r.Context()) - isAdmin := claims != nil && claims.HasRole("admin") + admin := isAdmin(r) data := DashboardData{ - PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, - IsAdmin: isAdmin, + PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: admin}, } - if isAdmin { + if admin { accounts, err := u.db.ListAccounts() if err != nil { u.renderError(w, r, http.StatusInternalServerError, "failed to load accounts") diff --git a/internal/ui/handlers_policy.go b/internal/ui/handlers_policy.go index 2298924..eedbdfa 100644 --- a/internal/ui/handlers_policy.go +++ b/internal/ui/handlers_policy.go @@ -61,7 +61,7 @@ func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) { } data := PoliciesData{ - PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, + PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)}, Rules: views, AllActions: allActionStrings, } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index a8a57a9..5a93cd3 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -592,6 +592,13 @@ func (u *UIServer) clientIP(r *http.Request) string { return middleware.ClientIP(r, proxyIP) } +// isAdmin reports whether the authenticated user holds the "admin" role. +// Returns false if claims are absent. +func isAdmin(r *http.Request) bool { + claims := claimsFromContext(r.Context()) + return claims != nil && claims.HasRole("admin") +} + // actorName resolves the username of the currently authenticated user from the // request context. Returns an empty string if claims are absent or the account // cannot be found; callers should treat an empty string as "not logged in". @@ -617,6 +624,10 @@ type PageData struct { // ActorName is the username of the currently logged-in user, populated by // handlers so the base template can display it in the navigation bar. ActorName string + // IsAdmin is true when the logged-in user holds the "admin" role. + // Used by the base template to conditionally render admin-only navigation + // links (SEC-09: non-admin users must not see links they cannot access). + IsAdmin bool } // LoginData is the view model for the login page. @@ -632,7 +643,6 @@ type LoginData struct { // DashboardData is the view model for the dashboard page. type DashboardData struct { PageData - IsAdmin bool RecentEvents []*db.AuditEventView TotalAccounts int ActiveAccounts int diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 7c83386..89b54c9 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -631,3 +631,121 @@ func TestLoginLockedAccountShowsInvalidCredentials(t *testing.T) { t.Error("wrong password response does not contain 'invalid credentials'") } } + +// ---- SEC-09: admin nav link visibility tests ---- + +// issueUserSession creates a human account with the "user" role (non-admin), +// issues a JWT, tracks it, and returns the raw token string. +func issueUserSession(t *testing.T, u *UIServer) string { + t.Helper() + acct, err := u.db.CreateAccount("regular-user", model.AccountTypeHuman, "") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + if err := u.db.SetRoles(acct.ID, []string{"user"}, nil); err != nil { + t.Fatalf("SetRoles: %v", err) + } + tok, claims, err := token.IssueToken(u.privKey, testIssuer, acct.UUID, []string{"user"}, time.Hour) + if err != nil { + t.Fatalf("IssueToken: %v", err) + } + if err := u.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil { + t.Fatalf("TrackToken: %v", err) + } + return tok +} + +// TestNonAdminDashboardHidesAdminNavLinks verifies that a non-admin user's +// dashboard does not contain links to admin-only pages (SEC-09). +func TestNonAdminDashboardHidesAdminNavLinks(t *testing.T) { + u := newTestUIServer(t) + mux := http.NewServeMux() + u.Register(mux) + + userToken := issueUserSession(t, u) + + req := authenticatedGET(t, userToken, "/dashboard") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + + body := rr.Body.String() + for _, adminPath := range []string{ + `href="/accounts"`, + `href="/audit"`, + `href="/policies"`, + `href="/pgcreds"`, + } { + if strings.Contains(body, adminPath) { + t.Errorf("non-admin dashboard contains admin link %s — SEC-09 violation", adminPath) + } + } + + // Dashboard link should still be present. + if !strings.Contains(body, `href="/dashboard"`) { + t.Error("dashboard link missing from non-admin nav") + } +} + +// TestAdminDashboardShowsAdminNavLinks verifies that an admin user's +// dashboard contains all admin navigation links. +func TestAdminDashboardShowsAdminNavLinks(t *testing.T) { + u := newTestUIServer(t) + mux := http.NewServeMux() + u.Register(mux) + + adminToken, _, _ := issueAdminSession(t, u) + + req := authenticatedGET(t, adminToken, "/dashboard") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + + body := rr.Body.String() + for _, adminPath := range []string{ + `href="/accounts"`, + `href="/audit"`, + `href="/policies"`, + `href="/pgcreds"`, + } { + if !strings.Contains(body, adminPath) { + t.Errorf("admin dashboard missing admin link %s", adminPath) + } + } +} + +// TestNonAdminProfileHidesAdminNavLinks verifies that the profile page +// also hides admin nav links for non-admin users (SEC-09). +func TestNonAdminProfileHidesAdminNavLinks(t *testing.T) { + u := newTestUIServer(t) + mux := http.NewServeMux() + u.Register(mux) + + userToken := issueUserSession(t, u) + + req := authenticatedGET(t, userToken, "/profile") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + + body := rr.Body.String() + for _, adminPath := range []string{ + `href="/accounts"`, + `href="/audit"`, + `href="/policies"`, + `href="/pgcreds"`, + } { + if strings.Contains(body, adminPath) { + t.Errorf("non-admin profile page contains admin link %s — SEC-09 violation", adminPath) + } + } +} diff --git a/web/templates/base.html b/web/templates/base.html index f6bbc7b..60b62df 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -12,10 +12,10 @@ MCIAS