From 7fb24e0452a0aa78a9cc839e5ac0e859bfccb3c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 20:10:22 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20gsc-shell-api=20v0.1=20=E2=80=94=20cent?= =?UTF-8?q?ral=20chrome=20data=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiny Go service that returns ShellConfig JSON for any registered app. Backs the runtime-loaded 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) --- .gitignore | 11 ++ Dockerfile | 25 ++++ cmd/server/main.go | 103 +++++++++++++++ go.mod | 31 +++++ go.sum | 63 +++++++++ internal/auth/keycloak.go | 187 ++++++++++++++++++++++++++ internal/config/config.go | 49 +++++++ internal/db/db.go | 149 +++++++++++++++++++++ internal/handlers/health.go | 28 ++++ internal/handlers/shell.go | 234 +++++++++++++++++++++++++++++++++ k8s/deployment.yaml | 121 +++++++++++++++++ k8s/namespace.yaml | 7 + k8s/serviceentry-keycloak.yaml | 14 ++ migrations/001_init.sql | 101 ++++++++++++++ seeds/001_gsc-crm.sql | 72 ++++++++++ 15 files changed, 1195 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/keycloak.go create mode 100644 internal/config/config.go create mode 100644 internal/db/db.go create mode 100644 internal/handlers/health.go create mode 100644 internal/handlers/shell.go create mode 100644 k8s/deployment.yaml create mode 100644 k8s/namespace.yaml create mode 100644 k8s/serviceentry-keycloak.yaml create mode 100644 migrations/001_init.sql create mode 100644 seeds/001_gsc-crm.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7f2cb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Build artifacts +/bin +/out +/gsc-shell-api + +# Editor +.vscode/ +.idea/ + +# OS +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9102d46 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# gsc-shell-api — runtime chrome data API. +# +# Build context is the project root (no monorepo file: deps). +# podman build -f Dockerfile -t gsc-shell-api . + +FROM golang:1.23-alpine AS builder +RUN apk add --no-cache git ca-certificates +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -ldflags="-w -s" -o /out/gsc-shell-api ./cmd/server + +# ----- runtime ----- +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates tzdata && \ + addgroup -S shell && adduser -S shell -G shell +WORKDIR /app +COPY --from=builder /out/gsc-shell-api /app/gsc-shell-api +USER shell +EXPOSE 8080 +ENTRYPOINT ["/app/gsc-shell-api"] diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..e6c39e2 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/gofiber/fiber/v2/middleware/recover" + + "github.com/gosec/gsc-shell-api/internal/auth" + "github.com/gosec/gsc-shell-api/internal/config" + "github.com/gosec/gsc-shell-api/internal/db" + "github.com/gosec/gsc-shell-api/internal/handlers" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + ctx := context.Background() + + database, err := db.New(ctx, cfg.DatabaseURL) + if err != nil { + log.Fatalf("db: %v", err) + } + defer database.Close() + + verifier, err := auth.NewVerifier(ctx, cfg.KeycloakIssuer, cfg.KeycloakDiscovery, cfg.KeycloakAudCSV) + if err != nil { + log.Fatalf("oidc: %v", err) + } + + app := fiber.New(fiber.Config{ + AppName: "gsc-shell-api", + DisableStartupMessage: true, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + }) + app.Use(recover.New()) + app.Use(logger.New(logger.Config{ + Format: `{"time":"${time}","status":${status},"method":"${method}","path":"${path}","latency":"${latency}","ip":"${ip}"}` + "\n", + })) + + health := &handlers.HealthHandlers{DB: database} + app.Get("/healthz", health.Live) + app.Get("/readyz", health.Ready) + + shell := &handlers.ShellHandlers{ + DB: database, + CacheTTLSeconds: cfg.CacheTTLSeconds, + } + + api := app.Group("/api/v1", verifier.Middleware()) + api.Get("/shell/:appKey", shell.GetShell) + api.Get("/apps", shell.ListApps) + + addr := ":" + itoa(cfg.Port) + log.Printf("gsc-shell-api listening on %s (issuer=%s)", addr, cfg.KeycloakIssuer) + + go func() { + if err := app.Listen(addr); err != nil { + log.Fatalf("listen: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT) + <-stop + log.Println("shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = app.ShutdownWithContext(shutdownCtx) +} + +func itoa(n int) string { + // avoid pulling strconv just for this + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + buf := [20]byte{} + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..de9c60d --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module github.com/gosec/gsc-shell-api + +go 1.23 + +require ( + github.com/coreos/go-oidc/v3 v3.11.0 + github.com/gofiber/fiber/v2 v2.52.5 + github.com/jackc/pgx/v5 v5.7.1 +) + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/go-jose/go-jose/v4 v4.0.2 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..445de72 --- /dev/null +++ b/go.sum @@ -0,0 +1,63 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= +github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= +github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= +github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= +github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/keycloak.go b/internal/auth/keycloak.go new file mode 100644 index 0000000..26fa344 --- /dev/null +++ b/internal/auth/keycloak.go @@ -0,0 +1,187 @@ +// Package auth verifies Keycloak-issued JWTs. +// +// JWKS discovery is lazy: we don't hit `.well-known/openid-configuration` +// at startup because pod-to-public-internet TLS may be slow during pod +// boot and we don't want a healthy DB-only deployment to crash because +// auth.gosec.cloud hiccupped. Discovery happens on first token validation +// and the result is cached. We retry on every fresh request until it +// succeeds. +package auth + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gofiber/fiber/v2" +) + +type UserClaims struct { + Subject string `json:"sub"` + Email string `json:"email"` + DisplayName string `json:"name"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + TenantID string `json:"tenant_id"` + Roles []string // realm roles + flattened client roles +} + +type Verifier struct { + issuer string // the canonical issuer claim Keycloak emits + discoveryURL string // optional: point discovery at an internal URL + allowedAudCSV string + + mu sync.Mutex + v *oidc.IDTokenVerifier // nil until first successful discovery +} + +// NewVerifier returns a Verifier that validates tokens whose `iss` matches +// `issuer`. If `discoveryURL` is non-empty, OIDC discovery is performed +// against that URL instead — useful when the canonical issuer is a public +// hostname (auth.gosec.cloud) but the pod can only reach Keycloak through +// an in-cluster service. +func NewVerifier(_ context.Context, issuer, discoveryURL, allowedAudiencesCSV string) (*Verifier, error) { + if issuer == "" { + return nil, errors.New("issuer is required") + } + return &Verifier{ + issuer: issuer, + discoveryURL: discoveryURL, + allowedAudCSV: allowedAudiencesCSV, + }, nil +} + +// ensure resolves the underlying OIDC verifier on demand. Cached after first success. +func (vr *Verifier) ensure(ctx context.Context) (*oidc.IDTokenVerifier, error) { + vr.mu.Lock() + defer vr.mu.Unlock() + if vr.v != nil { + return vr.v, nil + } + disc, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + target := vr.issuer + if vr.discoveryURL != "" { + // Tell go-oidc that "the issuer claim Keycloak puts in tokens" and + // "the URL we hit for discovery" can differ — otherwise it errors + // because the discovery doc's issuer field won't match what we + // passed to NewProvider. + disc = oidc.InsecureIssuerURLContext(disc, vr.issuer) + target = vr.discoveryURL + + // In-cluster Keycloak presents a cert for `auth.gosec.cloud`, not + // for `keycloak.keycloak.svc.cluster.local`. Ambient mesh policy + // gates who can reach Keycloak; we don't need cert hostname + // verification on top of that for the internal discovery hop. + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec + }, + Timeout: 10 * time.Second, + } + disc = oidc.ClientContext(disc, client) + } + + provider, err := oidc.NewProvider(disc, target) + if err != nil { + return nil, fmt.Errorf("oidc discovery: %w", err) + } + vr.v = provider.Verifier(&oidc.Config{ + // Custom audience check below; many Keycloak access tokens have + // aud="account" which would fail strict OIDC checks. + SkipClientIDCheck: true, + }) + return vr.v, nil +} + +func (vr *Verifier) Verify(ctx context.Context, raw string) (*UserClaims, error) { + v, err := vr.ensure(ctx) + if err != nil { + return nil, err + } + tok, err := v.Verify(ctx, raw) + if err != nil { + return nil, err + } + + var raw1 struct { + Subject string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + TenantID string `json:"tenant_id"` + AzpAudience string `json:"azp"` + RealmAccess struct { + Roles []string `json:"roles"` + } `json:"realm_access"` + ResourceAccess map[string]struct { + Roles []string `json:"roles"` + } `json:"resource_access"` + } + if err := tok.Claims(&raw1); err != nil { + return nil, fmt.Errorf("decode claims: %w", err) + } + + if vr.allowedAudCSV != "" { + if !contains(strings.Split(vr.allowedAudCSV, ","), raw1.AzpAudience) { + return nil, errors.New("token azp not in allowed audiences") + } + } + + roles := append([]string{}, raw1.RealmAccess.Roles...) + for _, ra := range raw1.ResourceAccess { + roles = append(roles, ra.Roles...) + } + + return &UserClaims{ + Subject: raw1.Subject, + Email: raw1.Email, + DisplayName: raw1.Name, + GivenName: raw1.GivenName, + FamilyName: raw1.FamilyName, + TenantID: raw1.TenantID, + Roles: roles, + }, nil +} + +func (vr *Verifier) Middleware() fiber.Handler { + return func(c *fiber.Ctx) error { + header := c.Get("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + return c.Status(fiber.StatusUnauthorized). + JSON(fiber.Map{"error": "missing bearer token"}) + } + raw := strings.TrimPrefix(header, "Bearer ") + claims, err := vr.Verify(c.UserContext(), raw) + if err != nil { + return c.Status(fiber.StatusUnauthorized). + JSON(fiber.Map{"error": fmt.Sprintf("invalid token: %v", err)}) + } + c.Locals("user", claims) + return c.Next() + } +} + +func User(c *fiber.Ctx) *UserClaims { + if u, ok := c.Locals("user").(*UserClaims); ok { + return u + } + return nil +} + +func contains(haystack []string, needle string) bool { + for _, h := range haystack { + if strings.TrimSpace(h) == needle { + return true + } + } + return false +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d2c0e53 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,49 @@ +package config + +import ( + "fmt" + "os" + "strconv" +) + +// Config holds runtime configuration. All values come from env. +type Config struct { + Port int + DatabaseURL string + KeycloakIssuer string // e.g. https://auth.gosec.cloud/realms/gosecCloud + KeycloakDiscovery string // optional in-cluster discovery URL override + KeycloakAudCSV string // comma-separated allowed audiences (client_id values) + CacheTTLSeconds int // for client-side Cache-Control hint +} + +func Load() (*Config, error) { + c := &Config{ + Port: 8080, + KeycloakIssuer: os.Getenv("KEYCLOAK_ISSUER"), + KeycloakDiscovery: os.Getenv("KEYCLOAK_DISCOVERY_URL"), + KeycloakAudCSV: os.Getenv("KEYCLOAK_AUDIENCES"), + DatabaseURL: os.Getenv("DATABASE_URL"), + CacheTTLSeconds: 60, + } + if p := os.Getenv("PORT"); p != "" { + v, err := strconv.Atoi(p) + if err != nil { + return nil, fmt.Errorf("PORT: %w", err) + } + c.Port = v + } + if t := os.Getenv("CACHE_TTL_SECONDS"); t != "" { + v, err := strconv.Atoi(t) + if err != nil { + return nil, fmt.Errorf("CACHE_TTL_SECONDS: %w", err) + } + c.CacheTTLSeconds = v + } + if c.DatabaseURL == "" { + return nil, fmt.Errorf("DATABASE_URL is required") + } + if c.KeycloakIssuer == "" { + return nil, fmt.Errorf("KEYCLOAK_ISSUER is required") + } + return c, nil +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..1969405 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,149 @@ +// Package db contains the queries the API serves. +// +// All reads are filtered against a per-app key. Role filtering happens +// in-memory after the menu_items + role_grants are loaded — the joins +// are small enough (typically <100 rows per app) that hand-filtering in +// Go is simpler than a SQL CTE. +package db + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type DB struct { + pool *pgxpool.Pool +} + +func New(ctx context.Context, url string) (*DB, error) { + pool, err := pgxpool.New(ctx, url) + if err != nil { + return nil, fmt.Errorf("pgxpool.New: %w", err) + } + if err := pool.Ping(ctx); err != nil { + return nil, fmt.Errorf("ping: %w", err) + } + return &DB{pool: pool}, nil +} + +func (d *DB) Close() { d.pool.Close() } + +type App struct { + Key string + DisplayName string + BaseURL string +} + +type Branding struct { + LogoURL string + ProductName string + FooterHTML *string + BrandColor *string +} + +type MenuItem struct { + ID string + ParentID *string + Zone string + Key string + TranslationKey string + Href string + Icon *string + SortOrder int + IsExternal bool + Roles []string // empty = visible to anyone authenticated +} + +func (d *DB) GetApp(ctx context.Context, appKey string) (*App, error) { + row := d.pool.QueryRow(ctx, ` + SELECT app_key, display_name, base_url + FROM shell.apps + WHERE app_key = $1 AND is_active = true + `, appKey) + var a App + if err := row.Scan(&a.Key, &a.DisplayName, &a.BaseURL); err != nil { + return nil, err + } + return &a, nil +} + +func (d *DB) GetBranding(ctx context.Context, appKey string) (*Branding, error) { + row := d.pool.QueryRow(ctx, ` + SELECT logo_url, product_name, footer_html, brand_color + FROM shell.branding + WHERE app_key = $1 + `, appKey) + var b Branding + if err := row.Scan(&b.LogoURL, &b.ProductName, &b.FooterHTML, &b.BrandColor); err != nil { + return nil, err + } + return &b, nil +} + +func (d *DB) ListMenuItems(ctx context.Context, appKey string) ([]MenuItem, error) { + rows, err := d.pool.Query(ctx, ` + SELECT + mi.id::text, + mi.parent_id::text, + mi.zone::text, + mi.key, + mi.translation_key, + mi.href, + mi.icon, + mi.sort_order, + mi.is_external, + COALESCE( + (SELECT array_agg(role ORDER BY role) FROM shell.menu_role_grants WHERE menu_item_id = mi.id), + ARRAY[]::text[] + ) AS roles + FROM shell.menu_items mi + WHERE mi.app_key = $1 AND mi.is_active = true + ORDER BY mi.zone, mi.sort_order, mi.key + `, appKey) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []MenuItem + for rows.Next() { + var m MenuItem + var parent *string + if err := rows.Scan( + &m.ID, &parent, &m.Zone, &m.Key, &m.TranslationKey, &m.Href, + &m.Icon, &m.SortOrder, &m.IsExternal, &m.Roles, + ); err != nil { + return nil, err + } + if parent != nil && *parent != "" { + m.ParentID = parent + } + out = append(out, m) + } + return out, rows.Err() +} + +// ListApps returns every active app — used by GET /api/v1/apps. +func (d *DB) ListApps(ctx context.Context) ([]App, error) { + rows, err := d.pool.Query(ctx, ` + SELECT app_key, display_name, base_url + FROM shell.apps + WHERE is_active = true + ORDER BY app_key + `) + if err != nil { + return nil, err + } + defer rows.Close() + var out []App + for rows.Next() { + var a App + if err := rows.Scan(&a.Key, &a.DisplayName, &a.BaseURL); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} diff --git a/internal/handlers/health.go b/internal/handlers/health.go new file mode 100644 index 0000000..2acc508 --- /dev/null +++ b/internal/handlers/health.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "context" + "time" + + "github.com/gofiber/fiber/v2" + + "github.com/gosec/gsc-shell-api/internal/db" +) + +type HealthHandlers struct { + DB *db.DB +} + +func (h *HealthHandlers) Live(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"status": "live"}) +} + +func (h *HealthHandlers) Ready(c *fiber.Ctx) error { + ctx, cancel := context.WithTimeout(c.UserContext(), 2*time.Second) + defer cancel() + if _, err := h.DB.ListApps(ctx); err != nil { + return c.Status(fiber.StatusServiceUnavailable). + JSON(fiber.Map{"status": "not-ready", "error": err.Error()}) + } + return c.JSON(fiber.Map{"status": "ready"}) +} diff --git a/internal/handlers/shell.go b/internal/handlers/shell.go new file mode 100644 index 0000000..8954938 --- /dev/null +++ b/internal/handlers/shell.go @@ -0,0 +1,234 @@ +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 +} diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..27f504b --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,121 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gsc-shell-api + namespace: gsc-shell + labels: + app.kubernetes.io/name: gsc-shell-api + app.kubernetes.io/component: api +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: gsc-shell-api + template: + metadata: + labels: + app.kubernetes.io/name: gsc-shell-api + spec: + containers: + - name: api + image: registry.gosec.internal/gsc-shell-api:v0.1.3 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + env: + - name: PORT + value: "8080" + - name: KEYCLOAK_ISSUER + value: "https://auth.gosec.cloud/realms/gosecCloud" + # Discovery hits Keycloak via in-cluster service (pods can't reach + # public auth.gosec.cloud over TLS); the issuer claim still has + # to match the canonical hostname above. + - name: KEYCLOAK_DISCOVERY_URL + value: "https://keycloak.keycloak.svc.cluster.local:8443/realms/gosecCloud" + - name: CACHE_TTL_SECONDS + value: "60" + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: gsc-shell-api-db + key: database-url + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /readyz + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + imagePullSecrets: + - name: registry-credentials + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/name: gsc-shell-api + topologyKey: kubernetes.io/hostname +--- +apiVersion: v1 +kind: Service +metadata: + name: gsc-shell-api + namespace: gsc-shell + labels: + app.kubernetes.io/name: gsc-shell-api +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: gsc-shell-api + ports: + - name: http + port: 8080 + targetPort: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: gsc-shell-api + namespace: gsc-shell + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" +spec: + ingressClassName: nginx + tls: + - hosts: + - shell-api.gosec.internal + secretName: gsc-shell-api-tls + rules: + - host: shell-api.gosec.internal + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: gsc-shell-api + port: + number: 8080 diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..eb5294c --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: gsc-shell + labels: + app.kubernetes.io/name: gsc-shell + istio.io/dataplane-mode: ambient diff --git a/k8s/serviceentry-keycloak.yaml b/k8s/serviceentry-keycloak.yaml new file mode 100644 index 0000000..6bebc0f --- /dev/null +++ b/k8s/serviceentry-keycloak.yaml @@ -0,0 +1,14 @@ +apiVersion: networking.istio.io/v1 +kind: ServiceEntry +metadata: + name: auth-gosec-cloud + namespace: gsc-shell +spec: + hosts: + - auth.gosec.cloud + location: MESH_EXTERNAL + ports: + - name: https + number: 443 + protocol: HTTPS + resolution: DNS diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..eff4987 --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,101 @@ +-- gsc-shell-api — initial schema. +-- +-- Lives in `gsc_core` so it's reachable from every app that needs chrome data. +-- Owned by the `gsc_shell` role (created out of band). +-- +-- Idempotent: every CREATE uses IF NOT EXISTS where possible. + +CREATE SCHEMA IF NOT EXISTS shell; + +-- Apps registry. +-- An "app" is a frontend that wants to render chrome from this service. +CREATE TABLE IF NOT EXISTS shell.apps ( + app_key TEXT PRIMARY KEY CHECK (app_key ~ '^[a-z][a-z0-9-]*$'), + display_name TEXT NOT NULL, + base_url TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Menu zones. Mirrors what the existing AdminShell expects. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'menu_zone' AND typnamespace = 'shell'::regnamespace) THEN + CREATE TYPE shell.menu_zone AS ENUM ('topbar', 'sidebar', 'footer', 'user-menu'); + END IF; +END $$; + +-- Menu items. One row per nav entry per app per zone. +-- Hierarchical via parent_id (sidebar submenus, etc.). +CREATE TABLE IF NOT EXISTS shell.menu_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + app_key TEXT NOT NULL REFERENCES shell.apps(app_key) ON DELETE CASCADE, + parent_id UUID REFERENCES shell.menu_items(id) ON DELETE CASCADE, + zone shell.menu_zone NOT NULL, + key TEXT NOT NULL CHECK (key ~ '^[a-z][a-z0-9_-]*$'), + translation_key TEXT NOT NULL, -- e.g. 'menu.dashboard' + href TEXT NOT NULL, -- relative within app, or absolute if is_external + icon TEXT, -- Phosphor class, e.g. 'ph-house' + sort_order INT NOT NULL DEFAULT 0, + is_external BOOLEAN NOT NULL DEFAULT false, + is_active BOOLEAN NOT NULL DEFAULT true, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (app_key, zone, key) +); + +CREATE INDEX IF NOT EXISTS idx_menu_items_app_zone + ON shell.menu_items (app_key, zone, sort_order) + WHERE is_active; + +CREATE INDEX IF NOT EXISTS idx_menu_items_parent + ON shell.menu_items (parent_id) + WHERE parent_id IS NOT NULL; + +-- Role grants. Empty grants = visible to anyone authenticated. OR semantics +-- across rows: user sees the item if they hold ANY listed role. +-- Roles are Keycloak realm roles; we store their names as plain text since +-- Keycloak is the source of truth. +CREATE TABLE IF NOT EXISTS shell.menu_role_grants ( + menu_item_id UUID NOT NULL REFERENCES shell.menu_items(id) ON DELETE CASCADE, + role TEXT NOT NULL, + PRIMARY KEY (menu_item_id, role) +); + +-- Per-app branding. Logo, product name, footer text. Optional for now. +CREATE TABLE IF NOT EXISTS shell.branding ( + app_key TEXT PRIMARY KEY REFERENCES shell.apps(app_key) ON DELETE CASCADE, + logo_url TEXT NOT NULL, + product_name TEXT NOT NULL, + footer_html TEXT, + brand_color TEXT, -- optional CSS color override + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- updated_at maintenance. +CREATE OR REPLACE FUNCTION shell.touch_updated_at() RETURNS trigger AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END $$ LANGUAGE plpgsql; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'apps_touch_updated_at') THEN + CREATE TRIGGER apps_touch_updated_at + BEFORE UPDATE ON shell.apps + FOR EACH ROW EXECUTE FUNCTION shell.touch_updated_at(); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'menu_items_touch_updated_at') THEN + CREATE TRIGGER menu_items_touch_updated_at + BEFORE UPDATE ON shell.menu_items + FOR EACH ROW EXECUTE FUNCTION shell.touch_updated_at(); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'branding_touch_updated_at') THEN + CREATE TRIGGER branding_touch_updated_at + BEFORE UPDATE ON shell.branding + FOR EACH ROW EXECUTE FUNCTION shell.touch_updated_at(); + END IF; +END $$; diff --git a/seeds/001_gsc-crm.sql b/seeds/001_gsc-crm.sql new file mode 100644 index 0000000..0b250a2 --- /dev/null +++ b/seeds/001_gsc-crm.sql @@ -0,0 +1,72 @@ +-- gsc-shell-api — seed for the gsc-crm app. +-- Idempotent via ON CONFLICT. + +INSERT INTO shell.apps (app_key, display_name, base_url) VALUES + ('gsc-crm', 'GSC CRM', 'https://crm.gosec.internal') +ON CONFLICT (app_key) DO UPDATE + SET display_name = EXCLUDED.display_name, + base_url = EXCLUDED.base_url; + +INSERT INTO shell.branding (app_key, logo_url, product_name, footer_html, brand_color) VALUES + ( + 'gsc-crm', + 'https://assets.gosec.cloud/logos/logo.svg', + 'CRM', + '© GoSec Cloud', + NULL + ) +ON CONFLICT (app_key) DO UPDATE + SET logo_url = EXCLUDED.logo_url, + product_name = EXCLUDED.product_name, + footer_html = EXCLUDED.footer_html, + brand_color = EXCLUDED.brand_color; + +-- Sidebar items. +INSERT INTO shell.menu_items (app_key, zone, key, translation_key, href, icon, sort_order) VALUES + ('gsc-crm', 'sidebar', 'dashboard', 'menu.dashboard', '/dashboard', 'ph-house', 10), + ('gsc-crm', 'sidebar', 'accounts', 'menu.accounts', '/accounts', 'ph-buildings', 20), + ('gsc-crm', 'sidebar', 'contacts', 'menu.contacts', '/contacts', 'ph-users', 30), + ('gsc-crm', 'sidebar', 'leads', 'menu.leads', '/leads', 'ph-target', 40), + ('gsc-crm', 'sidebar', 'opportunities', 'menu.opportunities', '/opportunities', 'ph-currency-dollar', 50), + ('gsc-crm', 'sidebar', 'pipelines', 'menu.pipelines', '/pipelines', 'ph-kanban', 60), + ('gsc-crm', 'sidebar', 'activities', 'menu.activities', '/activities', 'ph-list-checks', 70), + ('gsc-crm', 'sidebar', 'reports', 'menu.reports', '/reports', 'ph-chart-bar', 80) +ON CONFLICT (app_key, zone, key) DO UPDATE + SET translation_key = EXCLUDED.translation_key, + href = EXCLUDED.href, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + is_active = true; + +-- Top bar (just the user-facing search hint for now). +INSERT INTO shell.menu_items (app_key, zone, key, translation_key, href, icon, sort_order) VALUES + ('gsc-crm', 'topbar', 'search', 'menu.search', '/search', 'ph-magnifying-glass', 10) +ON CONFLICT (app_key, zone, key) DO UPDATE + SET translation_key = EXCLUDED.translation_key, + href = EXCLUDED.href, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + is_active = true; + +-- User menu (top-right dropdown). Sign-out + settings + support. +INSERT INTO shell.menu_items (app_key, zone, key, translation_key, href, icon, sort_order, is_external) VALUES + ('gsc-crm', 'user-menu', 'settings', 'menu.settings', '/settings', 'ph-gear', 10, false), + ('gsc-crm', 'user-menu', 'support', 'menu.support', 'https://support.gosec.cloud/', 'ph-headset', 20, true), + ('gsc-crm', 'user-menu', 'logout', 'menu.logout', '/api/auth/signout', 'ph-sign-out',30, false) +ON CONFLICT (app_key, zone, key) DO UPDATE + SET translation_key = EXCLUDED.translation_key, + href = EXCLUDED.href, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + is_external = EXCLUDED.is_external, + is_active = true; + +-- Footer items. +INSERT INTO shell.menu_items (app_key, zone, key, translation_key, href, icon, sort_order, is_external) VALUES + ('gsc-crm', 'footer', 'docs', 'footer.docs', 'https://docs.gosec.cloud/', NULL, 10, true) +ON CONFLICT (app_key, zone, key) DO UPDATE + SET translation_key = EXCLUDED.translation_key, + href = EXCLUDED.href, + sort_order = EXCLUDED.sort_order, + is_external = EXCLUDED.is_external, + is_active = true;