Files
mcias/internal/ui/ui.go
Kyle Isom 8d9d9da6f5 fix UI privilege escalation vulnerability
- Add requireAdminRole middleware to web UI that checks
  claims.HasRole("admin") and returns 403 if absent
- Apply middleware to all admin routes (accounts, policies,
  audit, dashboard, credentials)
- Remove redundant inline admin check from handleAdminResetPassword
- Profile routes correctly require only authentication, not admin

Security: The admin/adminGet middleware wrappers only called
requireCookieAuth (JWT validation) but never verified the admin
role. Any authenticated user could access admin endpoints
including role assignment. Fixed by inserting requireAdminRole
into the middleware chain for all admin routes.
2026-03-12 21:59:02 -07:00

704 lines
25 KiB
Go

// Package ui provides the HTMX-based management web interface for MCIAS.
//
// Security design:
// - Session tokens are stored as HttpOnly, Secure, SameSite=Strict cookies.
// They are never accessible to JavaScript.
// - CSRF protection uses the HMAC-signed Double-Submit Cookie pattern.
// The mcias_csrf cookie is non-HttpOnly so HTMX can include it in the
// X-CSRF-Token header on every mutating request. SameSite=Strict is the
// primary browser-level CSRF defence.
// - UI handlers call internal Go functions directly — no internal HTTP round-trips.
// - All templates are parsed once at startup via embed.FS; no dynamic template
// loading from disk at request time.
package ui
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net"
"net/http"
"sync"
"time"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/web"
)
const (
sessionCookieName = "mcias_session"
csrfCookieName = "mcias_csrf"
totpNonceTTL = 90 * time.Second // single-use TOTP step nonce lifetime
totpNonceBytes = 16 // 128 bits of entropy
)
// pendingLogin is a short-lived record created after password verification
// succeeds but before TOTP confirmation. It holds the account ID so the
// TOTP step never needs to re-transmit the password.
//
// Security: the nonce is single-use (deleted on first lookup) and expires
// after totpNonceTTL to bound the window of a stolen nonce.
type pendingLogin struct {
expiresAt time.Time
accountID int64
}
// UIServer serves the HTMX-based management UI.
type UIServer struct {
pendingLogins sync.Map // nonce (string) → *pendingLogin
tmpls map[string]*template.Template // page name → template set
db *db.DB
cfg *config.Config
logger *slog.Logger
csrf *CSRFManager
pubKey ed25519.PublicKey
privKey ed25519.PrivateKey
masterKey []byte
}
// issueTOTPNonce creates a random single-use nonce for the TOTP step and
// stores the account ID it corresponds to. Returns the hex-encoded nonce.
func (u *UIServer) issueTOTPNonce(accountID int64) (string, error) {
raw := make([]byte, totpNonceBytes)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("ui: generate TOTP nonce: %w", err)
}
nonce := hex.EncodeToString(raw)
u.pendingLogins.Store(nonce, &pendingLogin{
accountID: accountID,
expiresAt: time.Now().Add(totpNonceTTL),
})
return nonce, nil
}
// consumeTOTPNonce looks up and deletes the nonce, returning the associated
// account ID. Returns (0, false) if the nonce is unknown or expired.
func (u *UIServer) consumeTOTPNonce(nonce string) (int64, bool) {
v, ok := u.pendingLogins.LoadAndDelete(nonce)
if !ok {
return 0, false
}
pl, ok2 := v.(*pendingLogin)
if !ok2 {
return 0, false
}
if time.Now().After(pl.expiresAt) {
return 0, false
}
return pl.accountID, true
}
// dummyHash returns the pre-computed Argon2id PHC hash for constant-time dummy
// verification when an account is unknown or inactive (F-07).
// Delegates to auth.DummyHash() which uses sync.Once for one-time computation.
func (u *UIServer) dummyHash() string {
return auth.DummyHash()
}
// New constructs a UIServer, parses all templates, and returns it.
// Returns an error if template parsing fails.
func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) (*UIServer, error) {
csrf := newCSRFManager(masterKey)
funcMap := template.FuncMap{
"formatTime": func(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02 15:04:05")
},
"truncateJTI": func(jti string) string {
if len(jti) > 8 {
return jti[:8]
}
return jti
},
"string": func(v interface{}) string {
switch s := v.(type) {
case model.AccountStatus:
return string(s)
case model.AccountType:
return string(s)
default:
return fmt.Sprintf("%v", v)
}
},
"hasRole": func(roles []string, role string) bool {
for _, r := range roles {
if r == role {
return true
}
}
return false
},
"not": func(b bool) bool { return !b },
// derefInt64 safely dereferences a *int64, returning 0 for nil.
// Used in templates to compare owner IDs without triggering nil panics.
"derefInt64": func(p *int64) int64 {
if p == nil {
return 0
}
return *p
},
// isPGCredOwner returns true when actorID and cred are both non-nil
// and actorID matches cred.OwnerID. Safe to call with nil arguments.
"isPGCredOwner": func(actorID *int64, cred *model.PGCredential) bool {
if actorID == nil || cred == nil || cred.OwnerID == nil {
return false
}
return *actorID == *cred.OwnerID
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"gt": func(a, b int) bool { return a > b },
"lt": func(a, b int) bool { return a < b },
"prettyJSON": func(s string) string {
var v json.RawMessage
if json.Unmarshal([]byte(s), &v) != nil {
return s
}
pretty, err := json.MarshalIndent(v, "", " ")
if err != nil {
return s
}
return string(pretty)
},
}
// Parse shared templates (base layout + all fragments) into a base set.
// Each page template is then parsed into a clone of this base set so that
// competing "content"/"title" definitions do not collide.
sharedFiles := []string{
"templates/base.html",
"templates/fragments/account_row.html",
"templates/fragments/account_status.html",
"templates/fragments/roles_editor.html",
"templates/fragments/token_list.html",
"templates/fragments/totp_step.html",
"templates/fragments/error.html",
"templates/fragments/audit_rows.html",
"templates/fragments/pgcreds_form.html",
"templates/fragments/tags_editor.html",
"templates/fragments/policy_row.html",
"templates/fragments/policy_form.html",
"templates/fragments/password_reset_form.html",
"templates/fragments/password_change_form.html",
}
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
if err != nil {
return nil, fmt.Errorf("ui: parse shared templates: %w", err)
}
// Each page template defines "content" and "title" blocks; parsing them
// into separate clones prevents the last-defined block from winning.
pageFiles := map[string]string{
"login": "templates/login.html",
"dashboard": "templates/dashboard.html",
"accounts": "templates/accounts.html",
"account_detail": "templates/account_detail.html",
"audit": "templates/audit.html",
"audit_detail": "templates/audit_detail.html",
"policies": "templates/policies.html",
"pgcreds": "templates/pgcreds.html",
"profile": "templates/profile.html",
}
tmpls := make(map[string]*template.Template, len(pageFiles))
for name, file := range pageFiles {
clone, cloneErr := base.Clone()
if cloneErr != nil {
return nil, fmt.Errorf("ui: clone base templates for %s: %w", name, cloneErr)
}
if _, parseErr := clone.ParseFS(web.TemplateFS, file); parseErr != nil {
return nil, fmt.Errorf("ui: parse page template %s: %w", name, parseErr)
}
tmpls[name] = clone
}
srv := &UIServer{
db: database,
cfg: cfg,
pubKey: pub,
privKey: priv,
masterKey: masterKey,
logger: logger,
csrf: csrf,
tmpls: tmpls,
}
// Security (DEF-02): launch a background goroutine to evict expired TOTP
// nonces from pendingLogins. consumeTOTPNonce deletes entries on use, but
// entries abandoned by users who never complete step 2 would otherwise
// accumulate indefinitely, enabling a memory-exhaustion attack.
go srv.cleanupPendingLogins()
return srv, nil
}
// cleanupPendingLogins periodically evicts expired entries from pendingLogins.
// It runs every 5 minutes, which is well within the 90-second nonce TTL, so
// stale entries are removed before they can accumulate to any significant size.
func (u *UIServer) cleanupPendingLogins() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
u.pendingLogins.Range(func(key, value any) bool {
pl, ok := value.(*pendingLogin)
if !ok || now.After(pl.expiresAt) {
u.pendingLogins.Delete(key)
}
return true
})
}
}
// Register attaches all UI routes to mux, wrapped with security headers.
// All UI responses (pages, fragments, redirects, static assets) carry the
// headers added by securityHeaders.
func (u *UIServer) Register(mux *http.ServeMux) {
// Build a dedicated child mux for all UI routes so that securityHeaders
// can be applied once as a wrapping middleware rather than per-route.
uiMux := http.NewServeMux()
// Static assets — serve from the web/static/ sub-directory of the embed.
staticSubFS, err := fs.Sub(web.StaticFS, "static")
if err != nil {
panic(fmt.Sprintf("ui: static sub-FS: %v", err))
}
uiMux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
// Redirect root to login.
uiMux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
http.NotFound(w, r)
})
// Security (DEF-01, DEF-03): apply the same per-IP rate limit as the REST
// /v1/auth/login endpoint, using the same proxy-aware IP extraction so
// the rate limit is applied to real client IPs behind a reverse proxy.
var trustedProxy net.IP
if u.cfg.Server.TrustedProxy != "" {
trustedProxy = net.ParseIP(u.cfg.Server.TrustedProxy)
}
loginRateLimit := middleware.RateLimit(10, 10, trustedProxy)
// Auth routes (no session required).
uiMux.HandleFunc("GET /login", u.handleLoginPage)
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
uiMux.HandleFunc("POST /logout", u.handleLogout)
// Protected routes.
//
// Security: three distinct access levels:
// - authed: any valid session cookie (authenticated user)
// - admin: authed + admin role in JWT claims (mutating admin ops)
// - adminGet: authed + admin role (read-only admin pages, no CSRF)
authed := u.requireCookieAuth
admin := func(h http.HandlerFunc) http.Handler {
return authed(u.requireAdminRole(u.requireCSRF(http.HandlerFunc(h))))
}
adminGet := func(h http.HandlerFunc) http.Handler {
return authed(u.requireAdminRole(http.HandlerFunc(h)))
}
uiMux.Handle("GET /dashboard", adminGet(u.handleDashboard))
uiMux.Handle("GET /accounts", adminGet(u.handleAccountsList))
uiMux.Handle("POST /accounts", admin(u.handleCreateAccount))
uiMux.Handle("GET /accounts/{id}", adminGet(u.handleAccountDetail))
uiMux.Handle("PATCH /accounts/{id}/status", admin(u.handleUpdateAccountStatus))
uiMux.Handle("DELETE /accounts/{id}", admin(u.handleDeleteAccount))
uiMux.Handle("GET /accounts/{id}/roles/edit", adminGet(u.handleRolesEditForm))
uiMux.Handle("PUT /accounts/{id}/roles", admin(u.handleSetRoles))
uiMux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken))
uiMux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken))
uiMux.Handle("PUT /accounts/{id}/pgcreds", admin(u.handleSetPGCreds))
uiMux.Handle("POST /accounts/{id}/pgcreds/access", admin(u.handleGrantPGCredAccess))
uiMux.Handle("DELETE /accounts/{id}/pgcreds/access/{grantee}", admin(u.handleRevokePGCredAccess))
uiMux.Handle("GET /pgcreds", adminGet(u.handlePGCredsList))
uiMux.Handle("POST /pgcreds", admin(u.handleCreatePGCreds))
uiMux.Handle("GET /audit", adminGet(u.handleAuditPage))
uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
uiMux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail))
uiMux.Handle("GET /policies", adminGet(u.handlePoliciesPage))
uiMux.Handle("POST /policies", admin(u.handleCreatePolicyRule))
uiMux.Handle("PATCH /policies/{id}/enabled", admin(u.handleTogglePolicyRule))
uiMux.Handle("DELETE /policies/{id}", admin(u.handleDeletePolicyRule))
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword))
// Profile routes — accessible to any authenticated user (not admin-only).
uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage)))
uiMux.Handle("PUT /profile/password", authed(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword))))
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
// on the parent mux continue to take precedence per Go's routing rules.
mux.Handle("/", securityHeaders(uiMux))
}
// ---- Middleware ----
// requireCookieAuth validates the mcias_session cookie and injects claims.
// On failure, HTMX requests get HX-Redirect; browser requests get a redirect.
func (u *UIServer) requireCookieAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
u.redirectToLogin(w, r)
return
}
claims, err := validateSessionToken(u.pubKey, cookie.Value, u.cfg.Tokens.Issuer)
if err != nil {
u.clearSessionCookie(w)
u.redirectToLogin(w, r)
return
}
// Check revocation.
rec, err := u.db.GetTokenRecord(claims.JTI)
if err != nil || rec.IsRevoked() {
u.clearSessionCookie(w)
u.redirectToLogin(w, r)
return
}
ctx := contextWithClaims(r.Context(), claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// requireCSRF validates the CSRF token on mutating requests (POST/PUT/PATCH/DELETE).
// The token is read from X-CSRF-Token header (HTMX) or _csrf form field (fallback).
func (u *UIServer) requireCSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(csrfCookieName)
if err != nil || cookie.Value == "" {
http.Error(w, "CSRF cookie missing", http.StatusForbidden)
return
}
// Header takes precedence (HTMX sets it automatically via hx-headers on body).
formVal := r.Header.Get("X-CSRF-Token")
if formVal == "" {
// Fallback: parse form and read _csrf field.
// Security: limit body size to prevent memory exhaustion (gosec G120).
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if parseErr := r.ParseForm(); parseErr == nil {
formVal = r.FormValue("_csrf")
}
}
if !u.csrf.Validate(cookie.Value, formVal) {
http.Error(w, "CSRF token invalid", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// requireAdminRole checks that the authenticated user holds the "admin" role.
// Must be placed after requireCookieAuth in the middleware chain so that
// claims are available in the context.
//
// Security: This is the authoritative server-side check that prevents
// non-admin users from accessing admin-only UI endpoints. The JWT claims
// are populated from the database at login/renewal and signed with the
// server's Ed25519 private key, so they cannot be forged client-side.
func (u *UIServer) requireAdminRole(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := claimsFromContext(r.Context())
if claims == nil || !claims.HasRole("admin") {
u.renderError(w, r, http.StatusForbidden, "admin role required")
return
}
next.ServeHTTP(w, r)
})
}
// ---- Helpers ----
// isHTMX reports whether the request was initiated by HTMX.
func isHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
// redirectToLogin redirects to the login page, using HX-Redirect for HTMX.
func (u *UIServer) redirectToLogin(w http.ResponseWriter, r *http.Request) {
if isHTMX(r) {
w.Header().Set("HX-Redirect", "/login")
w.WriteHeader(http.StatusUnauthorized)
return
}
http.Redirect(w, r, "/login", http.StatusFound)
}
// clearSessionCookie expires the session cookie.
func (u *UIServer) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
// setCSRFCookies sets the mcias_csrf cookie and returns the header value to
// embed in the page/form.
func (u *UIServer) setCSRFCookies(w http.ResponseWriter) (string, error) {
cookieVal, headerVal, err := u.csrf.NewToken()
if err != nil {
return "", err
}
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: cookieVal,
Path: "/",
// Security: non-HttpOnly so that HTMX can embed it in hx-headers;
// SameSite=Strict is the primary CSRF defence for browser requests.
HttpOnly: false,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
return headerVal, nil
}
// render executes the named template, writing the result to w.
// Renders to a buffer first so partial template failures don't corrupt output.
// For page templates (dashboard, accounts, etc.) the page-specific template set
// is used; for fragment templates the name is looked up across all sets.
func (u *UIServer) render(w http.ResponseWriter, name string, data interface{}) {
tmpl := u.templateFor(name)
if tmpl == nil {
u.logger.Error("template not found", "template", name)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
u.logger.Error("template render error", "template", name, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(buf.Bytes())
}
// templateFor returns the template set that contains the named template.
// Page templates have a dedicated set; fragment templates exist in every set.
func (u *UIServer) templateFor(name string) *template.Template {
if t, ok := u.tmpls[name]; ok {
return t
}
// Fragment — available in any page set; pick the first one.
for _, t := range u.tmpls {
if t.Lookup(name) != nil {
return t
}
}
return nil
}
// renderError returns an error response appropriate for the request type.
func (u *UIServer) renderError(w http.ResponseWriter, r *http.Request, status int, msg string) {
if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = fmt.Fprintf(w, `<div class="alert alert-error" role="alert">%s</div>`, template.HTMLEscapeString(msg))
return
}
http.Error(w, msg, status)
}
// maxFormBytes limits the size of UI form submissions (1 MiB).
// Security: prevents memory exhaustion from oversized POST bodies (gosec G120).
const maxFormBytes = 1 << 20
// securityHeaders returns middleware that adds defensive HTTP headers to every
// UI response.
//
// Security rationale for each header:
// - Content-Security-Policy: restricts resource loading to same origin only,
// mitigating XSS escalation even if an attacker injects HTML (e.g. via a
// malicious stored username rendered in the admin panel).
// - X-Content-Type-Options: prevents MIME-sniffing; browsers must honour the
// declared Content-Type and not guess an executable type from body content.
// - X-Frame-Options: blocks the admin panel from being framed by a third-party
// origin, preventing clickjacking against admin actions.
// - Strict-Transport-Security: instructs browsers to use HTTPS for all future
// requests to this origin for two years, preventing TLS-strip on revisit.
// - Referrer-Policy: suppresses the Referer header on outbound navigations so
// JWTs or session identifiers embedded in URLs are not leaked to third parties.
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
h.Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
// clientIP returns the real client IP for the request, respecting the
// server's trusted-proxy setting (DEF-03). Delegates to middleware.ClientIP
// so the same extraction logic is used for rate limiting and audit logging.
func (u *UIServer) clientIP(r *http.Request) string {
var proxyIP net.IP
if u.cfg.Server.TrustedProxy != "" {
proxyIP = net.ParseIP(u.cfg.Server.TrustedProxy)
}
return middleware.ClientIP(r, proxyIP)
}
// 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".
func (u *UIServer) actorName(r *http.Request) string {
claims := claimsFromContext(r.Context())
if claims == nil {
return ""
}
acct, err := u.db.GetAccountByUUID(claims.Subject)
if err != nil {
return ""
}
return acct.Username
}
// ---- Page data types ----
// PageData is embedded in all page-level view structs.
type PageData struct {
CSRFToken string
Flash string
Error string
// 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
}
// LoginData is the view model for the login page.
type LoginData struct {
Error string
Username string // pre-filled on TOTP step
// Security (F-02): Password is no longer carried in the HTML form. Instead
// a short-lived server-side nonce is issued after successful password
// verification, and only the nonce is embedded in the TOTP step form.
Nonce string // single-use server-side nonce replacing the password hidden field
}
// DashboardData is the view model for the dashboard page.
type DashboardData struct {
PageData
RecentEvents []*db.AuditEventView
TotalAccounts int
ActiveAccounts int
}
// AccountsData is the view model for the accounts list page.
type AccountsData struct {
PageData
Accounts []*model.Account
}
// AccountDetailData is the view model for the account detail page.
type AccountDetailData struct {
Account *model.Account
// PGCred is nil if none stored or the account is not a system account.
PGCred *model.PGCredential
// PGCredGrants lists accounts that have been granted read access to PGCred.
// Only populated when the viewing actor is the credential owner.
PGCredGrants []*model.PGCredAccessGrant
// GrantableAccounts is the list of accounts the owner may grant access to.
GrantableAccounts []*model.Account
// ActorID is the DB id of the currently logged-in user; used in templates
// to decide whether to show the owner-only management controls.
ActorID *int64
PageData
Roles []string
AllRoles []string
Tags []string
Tokens []*model.TokenRecord
}
// AuditData is the view model for the audit log page.
type AuditData struct {
PageData
FilterType string
Events []*db.AuditEventView
EventTypes []string
Total int64
TotalPages int
Page int
}
// AuditDetailData is the view model for a single audit event detail page.
type AuditDetailData struct {
Event *db.AuditEventView
PageData
}
// PolicyRuleView is a single policy rule prepared for template rendering.
type PolicyRuleView struct {
Description string
RuleJSON string
CreatedAt string
UpdatedAt string
NotBefore string // empty if not set
ExpiresAt string // empty if not set
ID int64
Priority int
Enabled bool
IsExpired bool // true if expires_at is in the past
IsPending bool // true if not_before is in the future
}
// PoliciesData is the view model for the policies list page.
type PoliciesData struct {
PageData
Rules []*PolicyRuleView
AllActions []string
}
// ProfileData is the view model for the profile/settings page.
type ProfileData struct {
PageData
}
// PGCredsData is the view model for the "My PG Credentials" list page.
// It shows all pg_credentials sets accessible to the currently logged-in user:
// those they own and those they have been granted access to.
// UncredentialedAccounts is the list of system accounts that have no credentials
// yet, populated to drive the "New Credentials" create form on the same page.
// CredGrants maps credential ID to the list of access grants for that credential;
// only populated for credentials the actor owns.
// AllAccounts is used to populate the grant-access dropdown for owned credentials.
// ActorID is the DB id of the currently logged-in user.
type PGCredsData struct {
CredGrants map[int64][]*model.PGCredAccessGrant
ActorID *int64
PageData
Creds []*model.PGCredential
UncredentialedAccounts []*model.Account
AllAccounts []*model.Account
}