feat: gsc-shell-api v0.1 — central chrome data API
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>
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Build artifacts
|
||||||
|
/bin
|
||||||
|
/out
|
||||||
|
/gsc-shell-api
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -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"]
|
||||||
103
cmd/server/main.go
Normal file
103
cmd/server/main.go
Normal file
@@ -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:])
|
||||||
|
}
|
||||||
31
go.mod
Normal file
31
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
63
go.sum
Normal file
63
go.sum
Normal file
@@ -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=
|
||||||
187
internal/auth/keycloak.go
Normal file
187
internal/auth/keycloak.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
49
internal/config/config.go
Normal file
49
internal/config/config.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
149
internal/db/db.go
Normal file
149
internal/db/db.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
28
internal/handlers/health.go
Normal file
28
internal/handlers/health.go
Normal file
@@ -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"})
|
||||||
|
}
|
||||||
234
internal/handlers/shell.go
Normal file
234
internal/handlers/shell.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
121
k8s/deployment.yaml
Normal file
121
k8s/deployment.yaml
Normal file
@@ -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
|
||||||
7
k8s/namespace.yaml
Normal file
7
k8s/namespace.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: gsc-shell
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: gsc-shell
|
||||||
|
istio.io/dataplane-mode: ambient
|
||||||
14
k8s/serviceentry-keycloak.yaml
Normal file
14
k8s/serviceentry-keycloak.yaml
Normal file
@@ -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
|
||||||
101
migrations/001_init.sql
Normal file
101
migrations/001_init.sql
Normal file
@@ -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 $$;
|
||||||
72
seeds/001_gsc-crm.sql
Normal file
72
seeds/001_gsc-crm.sql
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user