Go 1.25 multi-tenant dashboard for GatewayAPI + Kuadrant policies using Gin, HTMX, GORM, and PostgreSQL.
- Multi-tenant organizations with bootstrap admin assignment.
- Built-in RBAC roles:
admin,editor,viewerand custom permissions model. - Service-account based Kubernetes/OpenShift control plane access (separate from user dashboard login).
- CRUD API for:
- Gateway (
gateway.networking.k8s.io/v1) - HTTPRoute (
gateway.networking.k8s.io/v1) - AuthPolicy (
kuadrant.io/v1) - RateLimitPolicy (
kuadrant.io/v1)
- Gateway (
- Namespace creation with selectable Istio label profiles.
- Namespace creation with selectable Istio instance + profile, plus org-level defaults.
- Swagger UI at
/swagger/index.html. - HTMX server-rendered dashboard pages.
cp .env.example .env # optional
make runThe project ships with a rootless runtime image based on ubi9/ubi-minimal (Dockerfile).
Build and push:
make image-build IMAGE=ghcr.io/<you>/qdash:dev CONTAINER_TOOL=podman
make image-push IMAGE=ghcr.io/<you>/qdash:dev CONTAINER_TOOL=podmanVersion metadata is embedded at build time via ldflags:
VERSION(default:git describe --tags --always --dirty)COMMIT(default: short git SHA)BUILD_DATE(default: UTC timestamp)
Example:
make build VERSION=0.2.0 COMMIT=$(git rev-parse --short HEAD) BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")The runtime UI uses the curated static spec from docs/docs.go.
To generate an annotation-based OpenAPI artifact (JSON/YAML) without overwriting runtime docs:
make swagger-genThis writes:
docs/generated/swagger.jsondocs/generated/swagger.yaml
Base manifests are in deploy/k8s/base.
- Create namespace:
kubectl apply -f deploy/k8s/base/namespace.yaml- Create runtime secret (recommended):
kubectl -n qdash-system create secret generic qdash-secret \
--from-literal=DATABASE_URL='postgres://postgres:postgres@postgresql:5432/qdash?sslmode=disable' \
--from-literal=OIDC_ISSUER_URL='https://issuer.example.com/realms/main' \
--from-literal=OIDC_CLIENT_ID='qdash' \
--from-literal=OIDC_CLIENT_SECRET='replace-me'- Apply manifests:
kubectl apply -k deploy/k8s/baseOptional template secret is provided at deploy/k8s/base/secret.example.yaml.
OpenShift overlay is available in deploy/k8s/overlays/openshift and adds:
Routeresource- SCC-compatible deployment security patch
- service account image pull secret wiring
- explicit rootless container runtime settings (
runAsNonRoot, dropped capabilities, no privilege escalation)
- Update host placeholders:
deploy/k8s/overlays/openshift/route.yamlspec.hostdeploy/k8s/overlays/openshift/patch-configmap.yamlOIDC_REDIRECT_URL
- Create image pull secret (if needed):
oc -n qdash-system create secret docker-registry qdash-pull-secret \
--docker-server=ghcr.io \
--docker-username=<username> \
--docker-password=<token> \
--docker-email=<email>- Apply overlay:
oc apply -k deploy/k8s/overlays/openshiftUse the helper script to bootstrap/update a dev deployment in OpenShift:
export DATABASE_URL='postgres://postgres:postgres@postgresql:5432/qdash?sslmode=disable'
export OIDC_ISSUER_URL='https://issuer.example.com/realms/main'
export OIDC_CLIENT_ID='qdash'
export OIDC_CLIENT_SECRET='replace-me'
# Optional:
# export QDASH_NAMESPACE='qdash-system'
# export QDASH_ROUTE_HOST='qdash-dev.apps.<your-cluster-domain>'
# export QDASH_PULL_SECRET_NAME='qdash-pull-secret'
# export QDASH_IMAGE='ghcr.io/<you>/qdash:dev'
make openshift-dev-upWhat it does:
- ensures namespace exists
- creates/updates
qdash-secret - applies OpenShift kustomize overlay
- patches Route host and
OIDC_REDIRECT_URL - restarts and waits for deployment rollout
BIND_ADDRESSdefault:8080DATABASE_URLdefaultpostgres://postgres:postgres@localhost:5432/qdash?sslmode=disableKUBECONFIGoptional for local cluster access- In-cluster mode uses pod service account automatically.
- OIDC browser login (required):
OIDC_ISSUER_URLOIDC_CLIENT_IDOIDC_CLIENT_SECRETOIDC_REDIRECT_URL(example:http://localhost:8080/auth/oidc/callback)OIDC_SCOPESdefaultopenid,profile,email,groups
- On each successful OIDC login, enabled organization mappings are applied:
- Group claim source is per-org
OIDCConfig.GroupClaim(defaultgroups) - Mappings support
subjectType(group,user,role) +externalValue - Matching mappings grant/update org membership role and custom permission
- Auth:
GET /loginGET /auth/oidc/startGET /auth/oidc/callbackPOST /logout
GET /api/v1/meGET|POST /api/v1/organizations- Org-scoped routes (
:orgSlugrequired and enforced):GET /api/v1/orgs/:orgSlug/gatewayclassesGET /api/v1/orgs/:orgSlug/istio-profilesGET /api/v1/orgs/:orgSlug/istio-instancesGET /api/v1/orgs/:orgSlug/namespacesPOST /api/v1/orgs/:orgSlug/namespacesPOST /api/v1/orgs/:orgSlug/namespaces/adopt(admin only; adopt pre-existing cluster namespace)GET|POST /api/v1/orgs/:orgSlug/gatewaysGET|POST /api/v1/orgs/:orgSlug/httproutesGET|POST /api/v1/orgs/:orgSlug/authpoliciesGET|POST /api/v1/orgs/:orgSlug/ratelimitpoliciesDELETE /api/v1/orgs/:orgSlug/{resource}/{namespace}/{name}GET|PUT /api/v1/orgs/:orgSlug/oidcGET|POST /api/v1/orgs/:orgSlug/oidc/mappingsDELETE /api/v1/orgs/:orgSlug/oidc/mappings/:mappingIDGET|POST /api/v1/orgs/:orgSlug/rbac/usersGET|POST /api/v1/orgs/:orgSlug/rbac/groupsDELETE /api/v1/orgs/:orgSlug/rbac/groups/:groupIDGET|POST /api/v1/orgs/:orgSlug/rbac/groups/:groupID/usersDELETE /api/v1/orgs/:orgSlug/rbac/groups/:groupID/users/:userIDGET|POST /api/v1/orgs/:orgSlug/rbac/groups/:groupID/permissionsDELETE /api/v1/orgs/:orgSlug/rbac/groups/:groupID/permissions/:permissionGET|POST /api/v1/orgs/:orgSlug/permissionsGET /api/v1/orgs/:orgSlug/audit-events?limit=100
Swagger UI: http://localhost:8080/swagger/index.html
export BASE_URL="http://localhost:8080"
export ORG_SLUG="platform-team"- Start OIDC login in browser, then reuse session cookie in curl:
curl -i "$BASE_URL/api/v1/me" \
-H "Cookie: qdash_session=<your-session-cookie>"- Create organization:
curl -sS -X POST "$BASE_URL/api/v1/organizations" \
-H "Content-Type: application/json" \
-H "Cookie: qdash_session=<your-session-cookie>" \
-d '{"name":"Platform Team"}'- Create namespace ownership record + cluster namespace:
curl -sS -X POST "$BASE_URL/api/v1/orgs/$ORG_SLUG/namespaces" \
-H "Content-Type: application/json" \
-H "Cookie: qdash_session=<your-session-cookie>" \
-d '{"name":"team-a","instance":"default","profile":"default","labels":["istio-injection=enabled"]}'Namespace create precedence:
- Request payload
instance/profile(if set) - Organization settings
defaultNamespaceInstance/defaultNamespaceProfile - Hard fallback:
default/default
- Upsert Gateway:
curl -sS -X POST "$BASE_URL/api/v1/orgs/$ORG_SLUG/gateways" \
-H "Content-Type: application/json" \
-H "Cookie: qdash_session=<your-session-cookie>" \
-d '{
"namespace":"team-a",
"name":"public-gateway",
"spec":{
"gatewayClassName":"openshift-default",
"listeners":[{"name":"http","protocol":"HTTP","port":80}]
}
}'- Example semantic validation failure (
400+fieldErrors):
curl -sS -X POST "$BASE_URL/api/v1/orgs/$ORG_SLUG/gateways" \
-H "Content-Type: application/json" \
-H "Cookie: qdash_session=<your-session-cookie>" \
-d '{"namespace":"team-a","name":"bad-gw","spec":{"listeners":[{"name":"http","protocol":"HTTP","port":80}]}}'Expected error shape:
{
"error": "semantic validation failed",
"fieldErrors": [
{"field": "spec.gatewayClassName", "message": "is required"}
]
}- Upsert HTTPRoute:
curl -sS -X POST "$BASE_URL/api/v1/orgs/$ORG_SLUG/httproutes" \
-H "Content-Type: application/json" \
-H "Cookie: qdash_session=<your-session-cookie>" \
-d '{
"namespace":"team-a",
"name":"frontend-route",
"spec":{
"parentRefs":[{"group":"gateway.networking.k8s.io","kind":"Gateway","name":"public-gateway"}],
"hostnames":["app.example.com"],
"rules":[{"backendRefs":[{"name":"frontend-svc","port":8080}]}]
}
}'- Upsert AuthPolicy:
curl -sS -X POST "$BASE_URL/api/v1/orgs/$ORG_SLUG/authpolicies" \
-H "Content-Type: application/json" \
-H "Cookie: qdash_session=<your-session-cookie>" \
-d '{
"namespace":"team-a",
"name":"frontend-authz",
"spec":{
"targetRef":{"group":"gateway.networking.k8s.io","kind":"HTTPRoute","name":"frontend-route"},
"rules":{"authorization":{"allow":[{"when":[{"key":"request.headers[x-api-key]","operator":"eq","values":["demo-key"]}]}]}}
}
}'- Upsert RateLimitPolicy:
curl -sS -X POST "$BASE_URL/api/v1/orgs/$ORG_SLUG/ratelimitpolicies" \
-H "Content-Type: application/json" \
-H "Cookie: qdash_session=<your-session-cookie>" \
-d '{
"namespace":"team-a",
"name":"frontend-ratelimit",
"spec":{
"targetRef":{"group":"gateway.networking.k8s.io","kind":"HTTPRoute","name":"frontend-route"},
"limits":{"tenant-default":{"rates":[{"limit":100,"window":"1m"}]}}
}
}'- Delete resources:
curl -sS -X DELETE "$BASE_URL/api/v1/orgs/$ORG_SLUG/ratelimitpolicies/team-a/frontend-ratelimit" \
-H "Cookie: qdash_session=<your-session-cookie>"
curl -sS -X DELETE "$BASE_URL/api/v1/orgs/$ORG_SLUG/authpolicies/team-a/frontend-authz" \
-H "Cookie: qdash_session=<your-session-cookie>"
curl -sS -X DELETE "$BASE_URL/api/v1/orgs/$ORG_SLUG/httproutes/team-a/frontend-route" \
-H "Cookie: qdash_session=<your-session-cookie>"
curl -sS -X DELETE "$BASE_URL/api/v1/orgs/$ORG_SLUG/gateways/team-a/public-gateway" \
-H "Cookie: qdash_session=<your-session-cookie>"- Configure org OIDC integration:
curl -sS -X PUT "$BASE_URL/api/v1/orgs/$ORG_SLUG/oidc" \
-H "Content-Type: application/json" \
-H "Cookie: qdash_session=<your-session-cookie>" \
-d '{
"issuerUrl":"https://sso.example.com/realms/platform",
"clientId":"qdash",
"clientSecret":"replace-me",
"groupClaim":"groups",
"usernameClaim":"email",
"enabled":true
}'- Create OIDC mapping:
curl -sS -X POST "$BASE_URL/api/v1/orgs/$ORG_SLUG/oidc/mappings" \
-H "Content-Type: application/json" \
-H "Cookie: qdash_session=<your-session-cookie>" \
-d '{
"subjectType":"group",
"externalValue":"platform-admins",
"mappedRole":"admin",
"customPermission":"security.approve"
}'Compatibility note: externalGroup is still accepted for legacy clients and is treated as externalValue when subjectType=group.
- Create custom permission:
curl -sS -X POST "$BASE_URL/api/v1/orgs/$ORG_SLUG/permissions" \
-H "Content-Type: application/json" \
-H "Cookie: qdash_session=<your-session-cookie>" \
-d '{
"name":"security.approve",
"resource":"security",
"action":"approve",
"definition":"Approve production security policy changes"
}'- Upsert organization membership:
curl -sS -X POST "$BASE_URL/api/v1/orgs/$ORG_SLUG/rbac/users" \
-H "Content-Type: application/json" \
-H "Cookie: qdash_session=<your-session-cookie>" \
-d '{
"email":"alice@example.com",
"role":"editor",
"customPermissions":["gateway.write","security.read"]
}'- Create group and assign permissions/members:
GROUP_ID=$(
curl -sS -X POST "$BASE_URL/api/v1/orgs/$ORG_SLUG/rbac/groups" \
-H "Content-Type: application/json" \
-H "Cookie: qdash_session=<your-session-cookie>" \
-d '{"name":"gateway-editors"}' | sed -n 's/.*"id":"\\([^"]*\\)".*/\\1/p'
)
curl -sS -X POST "$BASE_URL/api/v1/orgs/$ORG_SLUG/rbac/groups/$GROUP_ID/permissions" \
-H "Content-Type: application/json" \
-H "Cookie: qdash_session=<your-session-cookie>" \
-d '{"permission":"gateway.write"}'
curl -sS -X POST "$BASE_URL/api/v1/orgs/$ORG_SLUG/rbac/groups/$GROUP_ID/users" \
-H "Content-Type: application/json" \
-H "Cookie: qdash_session=<your-session-cookie>" \
-d '{"email":"alice@example.com"}'- Verify org security/rbac state:
curl -sS "$BASE_URL/api/v1/orgs/$ORG_SLUG/oidc" \
-H "Cookie: qdash_session=<your-session-cookie>"
curl -sS "$BASE_URL/api/v1/orgs/$ORG_SLUG/oidc/mappings" \
-H "Cookie: qdash_session=<your-session-cookie>"
curl -sS "$BASE_URL/api/v1/orgs/$ORG_SLUG/permissions" \
-H "Cookie: qdash_session=<your-session-cookie>"
curl -sS "$BASE_URL/api/v1/orgs/$ORG_SLUG/rbac/users" \
-H "Cookie: qdash_session=<your-session-cookie>"
curl -sS "$BASE_URL/api/v1/orgs/$ORG_SLUG/rbac/groups" \
-H "Cookie: qdash_session=<your-session-cookie>"
curl -sS "$BASE_URL/api/v1/orgs/$ORG_SLUG/rbac/groups/$GROUP_ID/users" \
-H "Cookie: qdash_session=<your-session-cookie>"
curl -sS "$BASE_URL/api/v1/orgs/$ORG_SLUG/rbac/groups/$GROUP_ID/permissions" \
-H "Cookie: qdash_session=<your-session-cookie>"- Check recent audit events:
curl -sS "$BASE_URL/api/v1/orgs/$ORG_SLUG/audit-events?limit=20" \
-H "Cookie: qdash_session=<your-session-cookie>"-
401 authentication required- Cause: missing or expired
qdash_sessioncookie. - Fix: run browser login again (
/auth/oidc/start) and reuse the fresh cookie in curl.
- Cause: missing or expired
-
403 forbiddenor403 admin role required- Cause: user has no matching org permission/role for endpoint.
- Fix: check membership via
GET /api/v1/orgs/$ORG_SLUG/rbac/users, then update withPOST /api/v1/orgs/$ORG_SLUG/rbac/users.
-
404 organization not found or no membership- Cause: wrong
orgSlugor user not assigned to that organization. - Fix: confirm slug via
GET /api/v1/organizations; ensure membership exists.
- Cause: wrong
-
403 namespace is not owned by this organization- Cause: resource apply/delete/list attempted in unclaimed namespace.
- Fix: create namespace via
POST /api/v1/orgs/$ORG_SLUG/namespacesor adopt existing namespace viaPOST /api/v1/orgs/$ORG_SLUG/namespaces/adopt(admin only).
-
400 semantic validation failedwithfieldErrors- Cause: resource
specfails server-side semantic checks (for example missinggatewayClassName, invalid ports, invalid rate window). - Fix: inspect
fieldErrors[].fieldandfieldErrors[].message, adjust payload, retry.
- Cause: resource
-
502from resource operations- Cause: Kubernetes API or CRD interaction failed from service-account context.
- Fix: verify cluster connectivity, service account RBAC, and CRDs installation (
Gateway,HTTPRoute,AuthPolicy,RateLimitPolicy).
-
Transport security
- Terminate TLS at ingress/route and enforce HTTPS redirects.
- Set
OIDC_REDIRECT_URLto an HTTPS callback URL in production.
-
Session and cookie security
- Ensure session cookies are
Secure,HttpOnly, and scoped to the correct domain/path. - Use short session TTLs and require re-login on inactivity.
- Ensure session cookies are
-
Secrets and credentials
- Store
DATABASE_URL,OIDC_CLIENT_SECRET, and other credentials in Kubernetes/OpenShift Secrets, not ConfigMaps. - Rotate OIDC client secrets and DB credentials regularly.
- Avoid committing real secret values to git history.
- Store
-
Database security and reliability
- Use PostgreSQL TLS (
sslmode=requireor stronger) for non-local environments. - Enable automated backups and verify restore procedures.
- Set DB connection limits and monitor saturation.
- Use PostgreSQL TLS (
-
Kubernetes/OpenShift RBAC scope
- Keep service account permissions least-privilege: only required verbs/resources for managed CRDs and namespaces.
- Review and prune
ClusterRolepermissions periodically.
-
Pod/container security
- Keep rootless runtime settings enabled (
runAsNonRoot, drop capabilities, no privilege escalation). - Use read-only root filesystem where possible.
- Pin images by digest and scan images for CVEs in CI.
- Keep rootless runtime settings enabled (
-
Availability and scaling
- Configure readiness/liveness probes and conservative startup thresholds.
- Set CPU/memory requests and limits for predictable scheduling.
- Run multiple replicas behind a stable Service for high availability.
-
Observability and auditing
- Forward application logs and Kubernetes events to centralized logging.
- Monitor audit events (
/api/v1/orgs/:orgSlug/audit-events) for privileged actions and failed operations. - Add alerting for repeated
401/403/502spikes.
-
API governance
- Protect API and web endpoints with ingress rate limits/WAF where available.
- Keep Swagger docs in sync with deployed behavior for operators and client SDKs.
-
TLS + external exposure
- OpenShift Route host/TLS edge: deploy/k8s/overlays/openshift/route.yaml
- OIDC callback URL config: deploy/k8s/base/configmap.yaml, deploy/k8s/overlays/openshift/patch-configmap.yaml, .env.example
-
Secret handling
- Runtime secret template: deploy/k8s/base/secret.example.yaml
- Deployment env secret wiring: deploy/k8s/base/deployment.yaml
-
Service account + cluster permissions
- Service account object: deploy/k8s/base/serviceaccount.yaml
- Cluster role scope: deploy/k8s/base/clusterrole.yaml
- Binding to SA: deploy/k8s/base/clusterrolebinding.yaml
- OpenShift SA patch/image pull secret: deploy/k8s/overlays/openshift/patch-serviceaccount.yaml
-
Rootless/container security
- Baseline pod security context + container security context: deploy/k8s/base/deployment.yaml
- OpenShift SCC-compatible security patch: deploy/k8s/overlays/openshift/patch-deployment.yaml
-
Availability and resource control
- Replicas, probes, requests/limits: deploy/k8s/base/deployment.yaml
- Overlay-specific deployment adjustments: deploy/k8s/overlays/openshift/patch-deployment.yaml
-
Overlay composition
- OpenShift applied resources/patches: deploy/k8s/overlays/openshift/kustomization.yaml
-
App/runtime config defaults
- Local env defaults: .env.example
- Core app configuration in cluster: deploy/k8s/base/configmap.yaml
Set target namespace:
export NS=qdash-system- Confirm manifests render as expected
kubectl kustomize deploy/k8s/base >/tmp/qdash-base.yaml
oc kustomize deploy/k8s/overlays/openshift >/tmp/qdash-ocp.yaml- Verify required secrets/config exist
kubectl -n "$NS" get secret qdash-secret
kubectl -n "$NS" get configmap qdash-config -o yaml- Verify service account and cluster permissions
kubectl -n "$NS" get sa qdash
kubectl get clusterrole qdash-cluster-role -o yaml
kubectl get clusterrolebinding qdash-cluster-rolebinding -o yaml
kubectl auth can-i --as=system:serviceaccount:$NS:qdash get gateways.gateway.networking.k8s.io -A
kubectl auth can-i --as=system:serviceaccount:$NS:qdash create namespaces- Verify rootless + security context on Deployment
kubectl -n "$NS" get deploy qdash -o jsonpath='{.spec.template.spec.securityContext.runAsNonRoot}{"\n"}'
kubectl -n "$NS" get deploy qdash -o jsonpath='{.spec.template.spec.containers[0].securityContext.allowPrivilegeEscalation}{"\n"}'
kubectl -n "$NS" get deploy qdash -o jsonpath='{.spec.template.spec.containers[0].securityContext.capabilities.drop}{"\n"}'- Verify probes and resource limits
kubectl -n "$NS" get deploy qdash -o jsonpath='{.spec.template.spec.containers[0].readinessProbe.httpGet.path}{"\n"}'
kubectl -n "$NS" get deploy qdash -o jsonpath='{.spec.template.spec.containers[0].livenessProbe.httpGet.path}{"\n"}'
kubectl -n "$NS" get deploy qdash -o jsonpath='{.spec.template.spec.containers[0].resources}{"\n"}'- Verify OpenShift route and host (OpenShift only)
oc -n "$NS" get route qdash -o wide- Verify rollout and runtime health
kubectl -n "$NS" rollout status deploy/qdash
kubectl -n "$NS" get pods -l app=qdash -o wide
kubectl -n "$NS" logs deploy/qdash --tail=200- Verify API and Swagger availability
kubectl -n "$NS" port-forward svc/qdash 8080:80
curl -sS http://127.0.0.1:8080/healthz
curl -sS http://127.0.0.1:8080/swagger/doc.json | headAutomated variant:
make smoke-postUseful overrides:
NS=qdash-system APP=qdash SERVICE=qdash LOCAL_PORT=18080 make smoke-post
SKIP_ROUTE_CHECK=true make smoke-postWeb pages:
/organizations/:slug/auditto review audit history (latest first)./organizations/:slug/resourcesHTMX CRUD for Gateway/HTTPRoute/AuthPolicy/RateLimitPolicy in owned namespaces.- Uses resource-specific form fields per kind.
- Supports optional advanced JSON spec override for power users.
- Includes namespace management panel for create/claim and admin-only adopt of existing namespaces.
- Namespace creation uses selectable Istio profile labels from backend-supported profiles.
- Supports row-level Edit: load existing resource into the form (fields + advanced JSON).
Namespace isolation rules:
- Every resource CRUD/list call requires
namespace. - Namespace must be owned by the organization in DB (
org_namespacestable). - Namespace ownership is established when creating namespace via
POST /api/v1/orgs/:orgSlug/namespaces. - Existing namespace adoption requires admin role and uses
POST /api/v1/orgs/:orgSlug/namespaces/adopt.
Audit coverage:
- OIDC mapping decisions are logged.
- Namespace create/adopt and resource apply/delete actions are logged from both API and web flows.
This foundation now includes OIDC browser login (auth code + PKCE + nonce), session auth, and strict per-org authorization checks. Next milestone should implement refresh-token/session rotation and organization-level OIDC role/group auto-sync on login.