Tiny Go service that returns ShellConfig JSON for any registered app.
Backs the runtime-loaded <AppShell> component being added to
@limitless/ui (next).
Endpoints:
GET /api/v1/shell/{appKey} → app + branding + user + menus, ETag-cached
GET /api/v1/apps → registered app inventory
GET /healthz, /readyz → ops probes
Auth:
Keycloak Bearer JWT validated against the gosecCloud realm.
Discovery URL is overridable so pods can hit Keycloak via the
in-cluster service (https://keycloak.keycloak.svc.cluster.local:8443)
while still validating the canonical issuer (auth.gosec.cloud).
Lazy JWKS init — pod stays up if Keycloak is briefly unreachable.
Data model (gsc_core.shell):
apps · menu_items (zone enum: topbar/sidebar/footer/user-menu) ·
menu_role_grants (Keycloak realm roles, OR semantics, empty=all) ·
branding
Seed includes the 8 gsc-crm sidebar items + topbar search +
user-menu (settings/support/logout) + footer (docs).
K8s:
Namespace gsc-shell (ambient mesh).
Deployment 2 replicas, internal-only ingress shell-api.gosec.internal,
EJBCA SERVER cert.
ServiceEntry for auth.gosec.cloud (vestigial — discovery now uses
in-cluster path; keeping for ad-hoc curl from inside pods).
Added to keycloak/allow-keycloak-clients AuthorizationPolicy out of band.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
235 lines
6.4 KiB
Go
235 lines
6.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/gosec/gsc-shell-api/internal/auth"
|
|
"github.com/gosec/gsc-shell-api/internal/db"
|
|
)
|
|
|
|
type ShellHandlers struct {
|
|
DB *db.DB
|
|
CacheTTLSeconds int
|
|
}
|
|
|
|
// MenuItemDTO is the JSON shape sent to clients. Children flattened from
|
|
// menu_items via parent_id.
|
|
type MenuItemDTO struct {
|
|
ID string `json:"id"`
|
|
Key string `json:"key"`
|
|
TranslationKey string `json:"translationKey"`
|
|
Href string `json:"href"`
|
|
Icon *string `json:"icon,omitempty"`
|
|
IsExternal bool `json:"isExternal,omitempty"`
|
|
Children []*MenuItemDTO `json:"children,omitempty"`
|
|
}
|
|
|
|
type AppDTO struct {
|
|
Key string `json:"key"`
|
|
DisplayName string `json:"displayName"`
|
|
BaseURL string `json:"baseUrl"`
|
|
}
|
|
|
|
type BrandingDTO struct {
|
|
LogoURL string `json:"logoUrl"`
|
|
ProductName string `json:"productName"`
|
|
FooterHTML *string `json:"footerHtml,omitempty"`
|
|
BrandColor *string `json:"brandColor,omitempty"`
|
|
}
|
|
|
|
type UserDTO struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email,omitempty"`
|
|
DisplayName string `json:"displayName"`
|
|
GivenName string `json:"givenName,omitempty"`
|
|
FamilyName string `json:"familyName,omitempty"`
|
|
TenantID string `json:"tenantId,omitempty"`
|
|
Roles []string `json:"roles"`
|
|
}
|
|
|
|
type ShellConfigDTO struct {
|
|
Version int `json:"version"`
|
|
App AppDTO `json:"app"`
|
|
Branding BrandingDTO `json:"branding"`
|
|
User UserDTO `json:"user"`
|
|
Menus map[string][]*MenuItemDTO `json:"menus"`
|
|
}
|
|
|
|
// GetShell returns the full chrome configuration for an app, filtered by the
|
|
// caller's roles. Honors If-None-Match for ETag-based caching.
|
|
func (h *ShellHandlers) GetShell(c *fiber.Ctx) error {
|
|
appKey := c.Params("appKey")
|
|
user := auth.User(c)
|
|
if user == nil {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthenticated"})
|
|
}
|
|
|
|
ctx := c.UserContext()
|
|
|
|
app, err := h.DB.GetApp(ctx, appKey)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.Status(fiber.StatusNotFound).
|
|
JSON(fiber.Map{"error": fmt.Sprintf("unknown app: %s", appKey)})
|
|
}
|
|
return c.Status(fiber.StatusInternalServerError).
|
|
JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
branding, err := h.DB.GetBranding(ctx, appKey)
|
|
if err != nil {
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return c.Status(fiber.StatusInternalServerError).
|
|
JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
// Branding is optional — fall back to app display name.
|
|
branding = &db.Branding{ProductName: app.DisplayName}
|
|
}
|
|
|
|
items, err := h.DB.ListMenuItems(ctx, appKey)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).
|
|
JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
// Filter by role grants (OR semantics; empty grants = visible to everyone authenticated).
|
|
visible := filterByRoles(items, user.Roles)
|
|
|
|
// Group by zone, build trees on parent_id.
|
|
zones := buildZones(visible)
|
|
|
|
// Sort roles for stable output (and stable ETag).
|
|
sortedRoles := append([]string{}, user.Roles...)
|
|
sort.Strings(sortedRoles)
|
|
|
|
cfg := ShellConfigDTO{
|
|
Version: 1,
|
|
App: AppDTO{
|
|
Key: app.Key,
|
|
DisplayName: app.DisplayName,
|
|
BaseURL: app.BaseURL,
|
|
},
|
|
Branding: BrandingDTO{
|
|
LogoURL: branding.LogoURL,
|
|
ProductName: branding.ProductName,
|
|
FooterHTML: branding.FooterHTML,
|
|
BrandColor: branding.BrandColor,
|
|
},
|
|
User: UserDTO{
|
|
ID: user.Subject,
|
|
Email: user.Email,
|
|
DisplayName: user.DisplayName,
|
|
GivenName: user.GivenName,
|
|
FamilyName: user.FamilyName,
|
|
TenantID: user.TenantID,
|
|
Roles: sortedRoles,
|
|
},
|
|
Menus: zones,
|
|
}
|
|
|
|
body, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).
|
|
JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
etag := fmt.Sprintf(`"%s"`, hashShort(body))
|
|
if match := c.Get("If-None-Match"); match == etag {
|
|
return c.Status(fiber.StatusNotModified).Send(nil)
|
|
}
|
|
|
|
c.Set("ETag", etag)
|
|
c.Set("Cache-Control",
|
|
fmt.Sprintf("private, max-age=%d", h.CacheTTLSeconds))
|
|
c.Set("Content-Type", "application/json")
|
|
return c.Status(fiber.StatusOK).Send(body)
|
|
}
|
|
|
|
// ListApps is unauthenticated-friendly: just an inventory of registered apps.
|
|
// Useful for service-discovery-style callers.
|
|
func (h *ShellHandlers) ListApps(c *fiber.Ctx) error {
|
|
apps, err := h.DB.ListApps(c.UserContext())
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).
|
|
JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
out := make([]AppDTO, 0, len(apps))
|
|
for _, a := range apps {
|
|
out = append(out, AppDTO{Key: a.Key, DisplayName: a.DisplayName, BaseURL: a.BaseURL})
|
|
}
|
|
return c.JSON(fiber.Map{"apps": out})
|
|
}
|
|
|
|
func filterByRoles(items []db.MenuItem, userRoles []string) []db.MenuItem {
|
|
if len(items) == 0 {
|
|
return items
|
|
}
|
|
roleSet := map[string]struct{}{}
|
|
for _, r := range userRoles {
|
|
roleSet[r] = struct{}{}
|
|
}
|
|
out := make([]db.MenuItem, 0, len(items))
|
|
for _, it := range items {
|
|
if len(it.Roles) == 0 {
|
|
out = append(out, it) // visible to all authenticated users
|
|
continue
|
|
}
|
|
for _, r := range it.Roles {
|
|
if _, ok := roleSet[r]; ok {
|
|
out = append(out, it)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func buildZones(items []db.MenuItem) map[string][]*MenuItemDTO {
|
|
// First, materialize every item as a DTO so we can wire children.
|
|
dto := make(map[string]*MenuItemDTO, len(items))
|
|
zoneOf := make(map[string]string, len(items))
|
|
for _, it := range items {
|
|
d := &MenuItemDTO{
|
|
ID: it.ID,
|
|
Key: it.Key,
|
|
TranslationKey: it.TranslationKey,
|
|
Href: it.Href,
|
|
Icon: it.Icon,
|
|
IsExternal: it.IsExternal,
|
|
}
|
|
dto[it.ID] = d
|
|
zoneOf[it.ID] = it.Zone
|
|
}
|
|
|
|
// Roots per zone, plus children attached via parent_id.
|
|
zones := map[string][]*MenuItemDTO{}
|
|
for _, it := range items {
|
|
d := dto[it.ID]
|
|
if it.ParentID == nil {
|
|
zones[it.Zone] = append(zones[it.Zone], d)
|
|
continue
|
|
}
|
|
parent, ok := dto[*it.ParentID]
|
|
if !ok {
|
|
// Parent was filtered out by role check; treat orphan as root.
|
|
zones[it.Zone] = append(zones[it.Zone], d)
|
|
continue
|
|
}
|
|
parent.Children = append(parent.Children, d)
|
|
}
|
|
return zones
|
|
}
|
|
|
|
func hashShort(body []byte) string {
|
|
sum := sha256.Sum256(body)
|
|
return hex.EncodeToString(sum[:8]) // 16 hex chars
|
|
}
|