From 93cc13639840b0d1074db8101e0f679162ab117e Mon Sep 17 00:00:00 2001 From: Nick Heyer Date: Mon, 16 Feb 2026 19:30:46 -0800 Subject: [PATCH 01/20] Oidc + rbac + auth-rewrite 90% complete, just need api token generation --- Makefile | 20 +- config.example.yaml | 44 ++ docker-compose.dev.yaml | 18 - docker-compose.yml | 20 +- docker/Dockerfile.discopanel | 4 +- go.mod | 46 +- go.sum | 252 ++++++- internal/auth/auth.go | 378 ---------- internal/auth/context.go | 30 + internal/auth/manager.go | 380 ++++++++++ internal/auth/middleware.go | 163 ---- internal/auth/oidc.go | 342 +++++++++ internal/auth/recovery.go | 84 --- internal/config/config.go | 41 + internal/db/models.go | 57 +- internal/db/store.go | 177 ++++- internal/rbac/mapping.go | 167 +++++ internal/rbac/rbac.go | 195 +++++ internal/rbac/resources.go | 97 +++ internal/rpc/server.go | 163 +++- internal/rpc/services/auth.go | 474 +++++------- internal/rpc/services/config.go | 27 - internal/rpc/services/role.go | 424 +++++++++++ internal/rpc/services/user.go | 162 ++-- internal/ws/hub.go | 87 ++- oidc/authelia/config/configuration.yml | 176 +++++ oidc/authelia/config/tls.crt | 20 + oidc/authelia/config/tls.key | 28 + oidc/authelia/config/users_database.yml | 21 + oidc/authelia/docker-compose.yaml | 72 ++ oidc/keycloak/config/realm.json | 106 +++ oidc/keycloak/docker-compose.yaml | 102 +++ proto/discopanel/v1/auth.proto | 118 +-- proto/discopanel/v1/common.proto | 37 +- proto/discopanel/v1/role.proto | 159 ++++ proto/discopanel/v1/user.proto | 18 +- web/discopanel/src/lib/api/rpc-client.ts | 3 + .../src/lib/components/auth-settings.svelte | 630 ++++++++-------- .../src/lib/components/role-settings.svelte | 701 ++++++++++++++++++ .../components/user-settings.svelte} | 268 +++---- web/discopanel/src/lib/stores/auth.ts | 180 +++-- web/discopanel/src/lib/stores/servers.ts | 5 +- web/discopanel/src/lib/utils/role-colors.ts | 16 + web/discopanel/src/routes/+layout.svelte | 68 +- web/discopanel/src/routes/+page.svelte | 5 +- web/discopanel/src/routes/login/+page.svelte | 382 +++++----- .../src/routes/profile/+page.svelte | 365 +++++---- .../src/routes/settings/+page.svelte | 189 +++-- 48 files changed, 5341 insertions(+), 2180 deletions(-) delete mode 100644 docker-compose.dev.yaml delete mode 100644 internal/auth/auth.go create mode 100644 internal/auth/context.go create mode 100644 internal/auth/manager.go delete mode 100644 internal/auth/middleware.go create mode 100644 internal/auth/oidc.go delete mode 100644 internal/auth/recovery.go create mode 100644 internal/rbac/mapping.go create mode 100644 internal/rbac/rbac.go create mode 100644 internal/rbac/resources.go create mode 100644 internal/rpc/services/role.go create mode 100644 oidc/authelia/config/configuration.yml create mode 100644 oidc/authelia/config/tls.crt create mode 100644 oidc/authelia/config/tls.key create mode 100644 oidc/authelia/config/users_database.yml create mode 100644 oidc/authelia/docker-compose.yaml create mode 100644 oidc/keycloak/config/realm.json create mode 100644 oidc/keycloak/docker-compose.yaml create mode 100644 proto/discopanel/v1/role.proto create mode 100644 web/discopanel/src/lib/components/role-settings.svelte rename web/discopanel/src/{routes/users/+page.svelte => lib/components/user-settings.svelte} (60%) create mode 100644 web/discopanel/src/lib/utils/role-colors.ts diff --git a/Makefile b/Makefile index 3a24abd..8ae99fd 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ -.PHONY: dev prod clean build build-frontend run deps test fmt lint check help kill-dev image dev-docker modules proto proto-clean proto-lint proto-format proto-breaking gen +.PHONY: dev prod clean build build-frontend run deps test fmt lint check help kill-dev image dev-docker dev-auth modules proto proto-clean proto-lint proto-format proto-breaking gen DATA_DIR := ./data +DOCKER_DATA_DIR := /opt/discopanel DB_FILE := $(DATA_DIR)/discopanel.db FRONTEND_DIR := web/discopanel DISCOPANEL_BIN := build/discopanel @@ -28,11 +29,13 @@ run: dev: clean run -# Build and run docker container for local dev -dev-docker: - @echo "Building and running Docker container for development..." - docker compose -f docker-compose.dev.yaml build --no-cache - docker compose -f docker-compose.dev.yaml up +# Build and run with OIDC provider (Keycloak) +dev-auth-%: clean + docker compose -f oidc/$*/docker-compose.yaml down -v --remove-orphans + @docker run --rm -v /tmp:/tmp alpine rm -rf /tmp/discopanel + @echo "Building and running with OIDC provider..." + docker compose -f oidc/$*/docker-compose.yaml build --no-cache + docker compose -f oidc/$*/docker-compose.yaml up # Production build and run prod: build-frontend @@ -90,6 +93,10 @@ clean: echo "Removing data directory..."; \ rm -rf $(DATA_DIR); \ fi + @if [ -d "$(DOCKER_DATA_DIR)" ]; then \ + echo "Removing docker data directory..."; \ + docker run --rm -v $(DOCKER_DATA_DIR):/tmp alpine sh -c 'rm -rf /tmp/*'; \ + fi @if [ -f "$(DISCOPANEL_BIN)" ]; then \ echo "Removing backend binary..."; \ rm -f $(DISCOPANEL_BIN); \ @@ -179,6 +186,7 @@ help: @echo " make prod - Build and run in production mode" @echo " make image - Build and push Docker image to :dev tag" @echo " make dev-docker - Build and run Docker container locally (no cache)" + @echo " make dev-auth - Build and run with OIDC provider (Keycloak)" @echo " make modules - Build and push all module Docker images" @echo " make clean - Remove data directory and build artifacts" @echo " make kill-dev - Kill any orphaned dev processes" diff --git a/config.example.yaml b/config.example.yaml index 475f1ce..7e29e74 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,5 +1,14 @@ # DiscoPanel Configuration File # This file contains all configuration options for DiscoPanel +# +# These can all be set via env, like we do in our docker compose example(s). For example: +# +# server: +# port: "8080" +# +# - is the same as - +# +# DISCOPANEL_SERVER_PORT="8080" # Server configuration for the DiscoPanel application itself server: @@ -31,6 +40,41 @@ storage: temp_dir: "./tmp" max_upload_size: 524288000 # 500MB in bytes +# Authentication configuration +auth: + session_timeout: 86400 # default: 24 hours + anonymous_access: false # Allow unauthenticated access + jwt_secret: "" # Leave empty to auto-generate + + # Local authentication (login via discopanel web ui) + local: + enabled: true # Enable local + allow_registration: false # Allow new users to register themselves via login + + # OIDC authentication (login via OIDC-compliant provider, ie: keycloak, authelia, authentik, etc.) + oidc: + enabled: false + issuer_uri: "" # OIDC Provider url (ie: http://authelia.local:9091, http://localhost:8180/realms/discopanel, etc.) + client_id: "" # Client ID registered with your OIDC (like "discopanel") + client_secret: "" # Client secret registered with your OIDC + redirect_url: "" # Where OIDC sends users after login (ie: "http://localhost:8080/api/v1/auth/oidc/callback") + scopes: ["openid", "profile", "email", "groups"] + role_claim: "groups" # The token claim that contains the user's groups (usually "groups") + role_mapping: {} # Mapping to groups if they arent the same name as discopanels (ie: {"my-admins": "admin", "my-users": "user"}) + +# Upload configuration +upload: + session_ttl: 240 # Upload session time-to-live in minutes (default: 4 hours) + default_chunk_size: 5242880 # 5MB default chunk size (client can override) + max_chunk_size: 10485760 # 10MB max chunk size (server enforced) + max_upload_size: 0 # Max total upload size in bytes (0 = unlimited) + +# Module configuration +module: + enabled: true + port_range_min: 8100 + port_range_max: 8199 + # Proxy configuration - ENABLE THIS FOR MINECRAFT ROUTING proxy: enabled: true diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml deleted file mode 100644 index afdc0ae..0000000 --- a/docker-compose.dev.yaml +++ /dev/null @@ -1,18 +0,0 @@ -services: - discopanel: - build: - context: . - dockerfile: docker/Dockerfile.discopanel - container_name: discopanel-dev - restart: "no" - network_mode: host - - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - /tmp/discopanel:/app/data - environment: - - DISCOPANEL_DATA_DIR=/app/data - - DISCOPANEL_HOST_DATA_PATH=/tmp/discopanel - - TZ=UTC - extra_hosts: - - "host.docker.internal:host-gateway" diff --git a/docker-compose.yml b/docker-compose.yml index 1151fc2..e2d77b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,12 +27,12 @@ services: # You can set this to any path you'd like, but the path must exist AND you must use the same # absolute paths below for the below env vars (in the environment section at the bottom). Example: # DISCOPANEL_DATA_DIR=/app/data - # DISCOPANEL_HOST_DATA_PATH=/home/user/data + # DISCOPANEL_HOST_DATA_PATH=/opt/discopanel/data # (See environment) - - /home/user/data:/app/data + - /opt/discopanel/data:/app/data - - ./backups:/app/backups - - ./tmp:/app/tmp + - /opt/discopanel/backups:/app/backups + - /tmp/discopanel:/app/tmp # Configuration file, uncomment if you are using a config file (optional, see config.example.yaml for all available options). #- ./config.yaml:/app/config.yaml:ro @@ -40,9 +40,19 @@ services: - DISCOPANEL_DATA_DIR=/app/data # IMPORTANT: THIS MUST BE SET TO THE SAME PATH AS THE SERVER DATA PATH IN "volumes" above - - DISCOPANEL_HOST_DATA_PATH=/home/user/data + - DISCOPANEL_HOST_DATA_PATH=/opt/discopanel/data - TZ=UTC + # ── Authentication ────────────────────────── + # Local auth (username/password) is on by default. + # You create your first admin account on first login. + # + # Want to let new users sign up on their own? + # - DISCOPANEL_AUTH_LOCAL_ALLOW_REGISTRATION=true + # + # Want single sign-on (OIDC) with Keycloak, Authelia, etc? + # See the oidc/ folder for examples. + # DONT FORGET THIS extra_hosts: - "host.docker.internal:host-gateway" diff --git a/docker/Dockerfile.discopanel b/docker/Dockerfile.discopanel index c0c0ec6..d08f811 100644 --- a/docker/Dockerfile.discopanel +++ b/docker/Dockerfile.discopanel @@ -7,7 +7,7 @@ COPY buf.yaml buf.gen.yaml ./ COPY proto/ ./proto/ RUN mkdir -p web/discopanel/static pkg/proto web/discopanel/src/lib/proto && buf generate -FROM node:22-alpine AS frontend-builder +FROM node:alpine AS frontend-builder ARG APP_VERSION ENV APP_VERSION=${APP_VERSION} @@ -23,7 +23,7 @@ COPY --from=proto-builder /app/web/discopanel/static/schemav1.yaml ./static/ RUN npm run build -FROM golang:1.24.5-alpine AS backend-builder +FROM golang:alpine AS backend-builder ARG APP_VERSION ENV APP_VERSION=${APP_VERSION} diff --git a/go.mod b/go.mod index 23ebd1d..2e5b07d 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,9 @@ require ( connectrpc.com/connect v1.19.1 connectrpc.com/grpcreflect v1.3.0 github.com/arkady-emelyanov/go-shellparse v1.0.3 + github.com/casbin/casbin/v3 v3.8.1 + github.com/casbin/gorm-adapter/v3 v3.41.0 + github.com/coreos/go-oidc/v3 v3.17.0 github.com/docker/docker v28.5.2+incompatible github.com/docker/go-connections v0.6.0 github.com/golang-jwt/jwt/v5 v5.3.0 @@ -14,12 +17,45 @@ require ( github.com/mholt/archives v0.1.5 github.com/robfig/cron/v3 v3.0.1 github.com/spf13/viper v1.20.1 - golang.org/x/crypto v0.40.0 + golang.org/x/crypto v0.46.0 + golang.org/x/oauth2 v0.35.0 google.golang.org/protobuf v1.36.10 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/sqlite v1.6.0 - gorm.io/gorm v1.30.1 + gorm.io/gorm v1.31.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/casbin/govaluate v1.10.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.22.0 // indirect + github.com/glebarez/sqlite v1.11.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.8.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/microsoft/go-mssqldb v1.9.5 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/shopspring/decimal v1.4.0 // indirect + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect + golang.org/x/sync v0.19.0 // indirect + gorm.io/driver/mysql v1.6.0 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/driver/sqlserver v1.6.3 // indirect + gorm.io/plugin/dbresolver v1.6.2 // indirect + modernc.org/libc v1.67.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.42.2 // indirect ) require ( @@ -75,9 +111,9 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect - golang.org/x/net v0.41.0 - golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/net v0.47.0 + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect diff --git a/go.sum b/go.sum index e7782bc..94d0937 100644 --- a/go.sum +++ b/go.sum @@ -19,8 +19,34 @@ connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4 connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc= connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro= @@ -31,12 +57,22 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/arkady-emelyanov/go-shellparse v1.0.3 h1:UrfVk/sGFefGG/1m4gHR46L9ZGNaTGTWQjO7g2iHhQ8= github.com/arkady-emelyanov/go-shellparse v1.0.3/go.mod h1:s00S9U8dfIEt/+dY39VsVNzggIeNZ193md1+vjF/Jeg= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= +github.com/casbin/casbin/v3 v3.8.1 h1:D4dEY4knePPR4YgNP5WZtWNaOxD0UK0LpPy9+zxtBwo= +github.com/casbin/casbin/v3 v3.8.1/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8= +github.com/casbin/gorm-adapter/v3 v3.41.0 h1:Xhpi0tfRP9aKPDWDf6dgBxHZ9UM6IophxxPIEGWqCNM= +github.com/casbin/gorm-adapter/v3 v3.41.0/go.mod h1:BQZRJhwUnwMpI+pT2m7/cUJwXxrHfzpBpPcNTyMGeGA= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -50,11 +86,16 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -64,6 +105,8 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -72,17 +115,32 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= +github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -102,26 +160,48 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +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.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +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/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -136,16 +216,26 @@ github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgo github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +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-sqlite3 v1.14.30 h1:bVreufq3EAIG1Quvws73du3/QgdeZ3myglJlrzSYYCY= github.com/mattn/go-sqlite3 v1.14.30/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ= github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4= +github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= +github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0= +github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q= github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= @@ -158,8 +248,12 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= +github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A= github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -170,19 +264,29 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= @@ -210,8 +314,10 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= @@ -255,8 +361,18 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -265,6 +381,8 @@ golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -283,6 +401,13 @@ golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -295,17 +420,32 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -313,8 +453,13 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -331,14 +476,39 @@ golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -346,8 +516,17 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= @@ -377,6 +556,11 @@ golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -428,15 +612,27 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= -gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4= -gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI= +gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= +gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -444,6 +640,34 @@ honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg= +modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74= +modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/auth/auth.go b/internal/auth/auth.go deleted file mode 100644 index 20d4016..0000000 --- a/internal/auth/auth.go +++ /dev/null @@ -1,378 +0,0 @@ -package auth - -import ( - "context" - "crypto/rand" - "encoding/base64" - "errors" - "fmt" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" - "github.com/nickheyer/discopanel/internal/db" - "golang.org/x/crypto/bcrypt" -) - -var ( - ErrInvalidCredentials = errors.New("invalid credentials") - ErrUserNotActive = errors.New("user is not active") - ErrAuthDisabled = errors.New("authentication is disabled") - ErrInvalidToken = errors.New("invalid token") - ErrSessionExpired = errors.New("session expired") - AnonymousUser = &db.User{ - ID: "anonymous", - Username: "anonymous", - Role: db.RoleAdmin, - IsActive: true, - } -) - -type Manager struct { - store *db.Store -} - -func NewManager(store *db.Store) *Manager { - return &Manager{ - store: store, - } -} - -// Hashes plaintext -func HashPassword(password string) (string, error) { - bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - return string(bytes), err -} - -// Compares a hashed pass with plaintext -func CheckPassword(hashedPassword, password string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) - return err == nil -} - -// Generate a random secret key -func GenerateSecretKey() (string, error) { - bytes := make([]byte, 32) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - return base64.URLEncoding.EncodeToString(bytes), nil -} - -func GetAnonUser() *db.User { - return AnonymousUser -} - -// Generate JWT token for a user -func (m *Manager) GenerateJWT(user *db.User, authConfig *db.AuthConfig) (string, error) { - claims := jwt.MapClaims{ - "user_id": user.ID, - "username": user.Username, - "role": user.Role, - "exp": time.Now().Add(time.Duration(authConfig.SessionTimeout) * time.Second).Unix(), - "iat": time.Now().Unix(), - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString([]byte(authConfig.JWTSecret)) -} - -// Validate JWT token and returns the claims -func (m *Manager) ValidateJWT(tokenString string, authConfig *db.AuthConfig) (jwt.MapClaims, error) { - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return []byte(authConfig.JWTSecret), nil - }) - - if err != nil { - return nil, err - } - - if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { - // Check expiration - if exp, ok := claims["exp"].(float64); ok { - if time.Now().Unix() > int64(exp) { - return nil, ErrSessionExpired - } - } - return claims, nil - } - - return nil, ErrInvalidToken -} - -// Authenticates a user and creates a session -func (m *Manager) Login(ctx context.Context, username, password string) (*db.User, string, error) { - // Check if auth is enabled - authConfig, _, err := m.store.GetAuthConfig(ctx) - if err != nil { - return nil, "", err - } - - if !authConfig.Enabled { - // Auth is disabled - return anon admin - return GetAnonUser(), "", nil - } - - // Get user by username - user, err := m.store.GetUserByUsername(ctx, username) - if err != nil { - return nil, "", ErrInvalidCredentials - } - - // Check password - if !CheckPassword(user.PasswordHash, password) { - return nil, "", ErrInvalidCredentials - } - - // Check if user is active - if !user.IsActive { - return nil, "", ErrUserNotActive - } - - // Generate JWT token - token, err := m.GenerateJWT(user, authConfig) - if err != nil { - return nil, "", err - } - - // Create session - session := &db.Session{ - UserID: user.ID, - Token: token, - ExpiresAt: time.Now().Add(time.Duration(authConfig.SessionTimeout) * time.Second), - } - if err := m.store.CreateSession(ctx, session); err != nil { - return nil, "", err - } - - // Update last login - now := time.Now() - user.LastLogin = &now - if err := m.store.UpdateUser(ctx, user); err != nil { - // Non-critical error, log but don't fail - } - - return user, token, nil -} - -// Logout deletes a user session -func (m *Manager) Logout(ctx context.Context, token string) error { - return m.store.DeleteSession(ctx, token) -} - -// Validate session token -func (m *Manager) ValidateSession(ctx context.Context, token string) (*db.User, error) { - // Check if auth is enabled - authConfig, _, err := m.store.GetAuthConfig(ctx) - if err != nil { - return nil, err - } - - if !authConfig.Enabled { - // Auth is disabled - return anon admin - return GetAnonUser(), nil - } - - // Auth enabled and no token means err - if token == "" { - return nil, ErrInvalidToken - } - - // Validate JWT - claims, err := m.ValidateJWT(token, authConfig) - if err != nil { - return nil, err - } - - // Get session from database - session, err := m.store.GetSession(ctx, token) - if err != nil { - return nil, ErrSessionExpired - } - - // Verify user ID matches - if userID, ok := claims["user_id"].(string); ok { - if session.UserID != userID { - return nil, ErrInvalidToken - } - } - - return session.User, nil -} - -// Create new user -func (m *Manager) CreateUser(ctx context.Context, username, email, password string, role db.UserRole) (*db.User, error) { - // Hash password - hashedPassword, err := HashPassword(password) - if err != nil { - return nil, err - } - - // Handle optional email - var emailPtr *string - if email != "" { - emailPtr = &email - } - - // Create user - user := &db.User{ - ID: uuid.New().String(), - Username: username, - Email: emailPtr, - PasswordHash: hashedPassword, - Role: role, - IsActive: true, - } - - if err := m.store.CreateUser(ctx, user); err != nil { - return nil, err - } - - return user, nil -} - -// Change user's password -func (m *Manager) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error { - // Get user - user, err := m.store.GetUser(ctx, userID) - if err != nil { - return err - } - - // Verify old password - if !CheckPassword(user.PasswordHash, oldPassword) { - return ErrInvalidCredentials - } - - // Hash new password - hashedPassword, err := HashPassword(newPassword) - if err != nil { - return err - } - - // Update password - user.PasswordHash = hashedPassword - return m.store.UpdateUser(ctx, user) -} - -// Reset user's password using recovery key -func (m *Manager) ResetPassword(ctx context.Context, username, recoveryKey, newPassword string) error { - // Get auth config - authConfig, _, err := m.store.GetAuthConfig(ctx) - if err != nil { - return err - } - - // Verify recovery key - if !CheckPassword(authConfig.RecoveryKeyHash, recoveryKey) { - return ErrInvalidCredentials - } - - // Get user - user, err := m.store.GetUserByUsername(ctx, username) - if err != nil { - return err - } - - // Hash new password - hashedPassword, err := HashPassword(newPassword) - if err != nil { - return err - } - - // Update password - user.PasswordHash = hashedPassword - return m.store.UpdateUser(ctx, user) -} - -// Initialize auth config -func (m *Manager) InitializeAuth(ctx context.Context) error { - authConfig, isNew, err := m.store.GetAuthConfig(ctx) - if err != nil { - return err - } - - if isNew || authConfig.JWTSecret == "" { - // Generate JWT secret - jwtSecret, err := GenerateSecretKey() - if err != nil { - return err - } - authConfig.JWTSecret = jwtSecret - - // Generate recovery key - recoveryKey, err := GenerateSecretKey() - if err != nil { - return err - } - authConfig.RecoveryKey = recoveryKey - - // Hash recovery key for storage - hashedRecovery, err := HashPassword(recoveryKey) - if err != nil { - return err - } - authConfig.RecoveryKeyHash = hashedRecovery - - // Save config - if err := m.store.SaveAuthConfig(ctx, authConfig); err != nil { - return err - } - - // Write recovery key to file (only on first initialization) - if err := m.saveRecoveryKey(recoveryKey); err != nil { - // Log error but don't fail - fmt.Printf("Warning: Could not save recovery key to file: %v\n", err) - } - } - - return nil -} - -// Save recovery key to a file -func (m *Manager) saveRecoveryKey(key string) error { - // Save to file - if err := SaveRecoveryKeyToFile(key); err != nil { - // If file save fails, at least print it - fmt.Printf("\n===========================================\n") - fmt.Printf("IMPORTANT: Save this recovery key securely!\n") - fmt.Printf("Recovery Key: %s\n", key) - fmt.Printf("===========================================\n\n") - return err - } - path, _ := GetRecoveryKeyPath() - fmt.Printf("\n===========================================\n") - fmt.Printf("Recovery key has been saved to: %s\n", path) - fmt.Printf("Recovery Key: %s\n", key) - fmt.Printf("IMPORTANT: Keep this key secure!\n") - fmt.Printf("===========================================\n\n") - - return nil -} - -// Check user has permission for an action -func CheckPermission(user *db.User, requiredRole db.UserRole) bool { - if user == nil { - return false - } - - // Admin can do everything - if user.Role == db.RoleAdmin { - return true - } - - // Editor can do editor and viewer actions - if user.Role == db.RoleEditor && (requiredRole == db.RoleEditor || requiredRole == db.RoleViewer) { - return true - } - - // Viewer can only do viewer actions - if user.Role == db.RoleViewer && requiredRole == db.RoleViewer { - return true - } - - return false -} diff --git a/internal/auth/context.go b/internal/auth/context.go new file mode 100644 index 0000000..9459999 --- /dev/null +++ b/internal/auth/context.go @@ -0,0 +1,30 @@ +package auth + +import "context" + +type contextKey string + +const UserContextKey contextKey = "authenticated_user" + +// AuthenticatedUser represents a validated user in context +type AuthenticatedUser struct { + ID string + Username string + Email string + Roles []string + Provider string // "local" or "oidc" +} + +// GetUserFromContext retrieves the authenticated user from context +func GetUserFromContext(ctx context.Context) *AuthenticatedUser { + user, ok := ctx.Value(UserContextKey).(*AuthenticatedUser) + if !ok { + return nil + } + return user +} + +// WithUser adds the authenticated user to context +func WithUser(ctx context.Context, user *AuthenticatedUser) context.Context { + return context.WithValue(ctx, UserContextKey, user) +} diff --git a/internal/auth/manager.go b/internal/auth/manager.go new file mode 100644 index 0000000..958e40b --- /dev/null +++ b/internal/auth/manager.go @@ -0,0 +1,380 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strconv" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/nickheyer/discopanel/internal/config" + "github.com/nickheyer/discopanel/internal/db" + "github.com/nickheyer/discopanel/internal/rbac" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrUserNotActive = errors.New("user is not active") + ErrInvalidToken = errors.New("invalid token") + ErrSessionExpired = errors.New("session expired") + ErrLocalAuthDisabled = errors.New("local authentication is disabled") + ErrRegistrationDisabled = errors.New("registration is disabled") + ErrSessionTimeoutMin = errors.New("session timeout must be at least 300 seconds (5 minutes)") +) + +// Auth override keys +const ( + settingLocalEnabled = "auth.local.enabled" + settingAllowRegistration = "auth.local.allow_registration" + settingAnonymousAccess = "auth.anonymous_access" + settingSessionTimeout = "auth.session_timeout" +) + +type Manager struct { + store *db.Store + enforcer *rbac.Enforcer + config *config.AuthConfig + jwtSecret []byte +} + +const jwtSecretSettingKey = "jwt_secret" + +func NewManager(store *db.Store, enforcer *rbac.Enforcer, cfg *config.AuthConfig) (*Manager, error) { + ctx := context.Background() + var secret []byte + + // Priority: config value → DB-stored value → generate + persist to DB + if cfg.JWTSecret != "" { + secret = []byte(cfg.JWTSecret) + } else { + stored, err := store.GetSystemSetting(ctx, jwtSecretSettingKey) + if err == nil && stored != "" { + secret, err = hex.DecodeString(stored) + if err != nil { + return nil, fmt.Errorf("failed to decode stored JWT secret: %w", err) + } + } else { + // Generate new secret and persist it + secret = make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, fmt.Errorf("failed to generate JWT secret: %w", err) + } + if err := store.SetSystemSetting(ctx, jwtSecretSettingKey, hex.EncodeToString(secret)); err != nil { + return nil, fmt.Errorf("failed to persist JWT secret: %w", err) + } + // Clean all sessions since old tokens are now invalid + _ = store.CleanAllSessions(ctx) + } + } + + m := &Manager{ + store: store, + enforcer: enforcer, + config: cfg, + jwtSecret: secret, + } + + m.loadSettingOverrides(ctx) + + return m, nil +} + +func (m *Manager) Login(ctx context.Context, username, password string) (*db.User, []string, string, time.Time, error) { + if !m.config.Local.Enabled { + return nil, nil, "", time.Time{}, ErrLocalAuthDisabled + } + + user, err := m.store.GetUserByUsernameAndProvider(ctx, username, "local") + if err != nil { + return nil, nil, "", time.Time{}, ErrInvalidCredentials + } + + if !checkPassword(user.PasswordHash, password) { + return nil, nil, "", time.Time{}, ErrInvalidCredentials + } + + if !user.IsActive { + return nil, nil, "", time.Time{}, ErrUserNotActive + } + + // Get user roles + roleNames, err := m.store.GetUserRoleNames(ctx, user.ID) + if err != nil { + return nil, nil, "", time.Time{}, fmt.Errorf("failed to get user roles: %w", err) + } + + // Generate token + expiresAt := time.Now().Add(time.Duration(m.config.SessionTimeout) * time.Second) + token, err := m.generateJWT(user.ID, user.Username, roleNames, expiresAt) + if err != nil { + return nil, nil, "", time.Time{}, err + } + + // Create session + session := &db.Session{ + ID: uuid.New().String(), + UserID: user.ID, + Token: token, + ExpiresAt: expiresAt, + } + if err := m.store.CreateSession(ctx, session); err != nil { + return nil, nil, "", time.Time{}, err + } + + // Update last login + now := time.Now() + user.LastLogin = &now + _ = m.store.UpdateUser(ctx, user) + + return user, roleNames, token, expiresAt, nil +} + +func (m *Manager) ValidateSession(ctx context.Context, token string) (*AuthenticatedUser, error) { + if token == "" { + return nil, ErrInvalidToken + } + + // Validate JWT + claims, err := m.validateJWT(token) + if err != nil { + return nil, err + } + + // Get session from database + session, err := m.store.GetSession(ctx, token) + if err != nil { + return nil, ErrSessionExpired + } + + userID, _ := claims["user_id"].(string) + if session.UserID != userID { + return nil, ErrInvalidToken + } + + // Get user + user, err := m.store.GetUser(ctx, userID) + if err != nil { + return nil, err + } + + if !user.IsActive { + return nil, ErrUserNotActive + } + + // Get roles + roleNames, err := m.store.GetUserRoleNames(ctx, user.ID) + if err != nil { + return nil, err + } + + authUser := &AuthenticatedUser{ + ID: user.ID, + Username: user.Username, + Roles: roleNames, + Provider: user.AuthProvider, + } + if user.Email != nil { + authUser.Email = *user.Email + } + + return authUser, nil +} + +func (m *Manager) Logout(ctx context.Context, token string) error { + return m.store.DeleteSession(ctx, token) +} + +func (m *Manager) CreateLocalUser(ctx context.Context, username, email, password string) (*db.User, error) { + hashedPassword, err := hashPassword(password) + if err != nil { + return nil, err + } + + var emailPtr *string + if email != "" { + emailPtr = &email + } + + user := &db.User{ + ID: uuid.New().String(), + Username: username, + Email: emailPtr, + PasswordHash: hashedPassword, + AuthProvider: "local", + IsActive: true, + } + + if err := m.store.CreateUser(ctx, user); err != nil { + return nil, err + } + + return user, nil +} + +func (m *Manager) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error { + user, err := m.store.GetUser(ctx, userID) + if err != nil { + return err + } + + if user.AuthProvider != "local" { + return errors.New("password change only available for local auth users") + } + + if !checkPassword(user.PasswordHash, oldPassword) { + return ErrInvalidCredentials + } + + hashedPassword, err := hashPassword(newPassword) + if err != nil { + return err + } + + user.PasswordHash = hashedPassword + return m.store.UpdateUser(ctx, user) +} + +func (m *Manager) AnonymousUser() *AuthenticatedUser { + return &AuthenticatedUser{ + ID: "anonymous", + Username: "anonymous", + Roles: []string{"anonymous"}, + Provider: "anonymous", + } +} + +func (m *Manager) IsAnonymousAccessEnabled() bool { + return m.config.AnonymousAccess +} + +func (m *Manager) IsAnyAuthEnabled() bool { + return m.config.Local.Enabled || m.config.OIDC.Enabled +} + +func (m *Manager) IsLocalAuthEnabled() bool { + return m.config.Local.Enabled +} + +func (m *Manager) IsRegistrationAllowed() bool { + return m.config.Local.Enabled && m.config.Local.AllowRegistration +} + +func (m *Manager) GetConfig() *config.AuthConfig { + return m.config +} + +// loadSettingOverrides reads SystemSetting overrides from the DB and applies +// them to the in-memory config, so DB values take precedence over config.yaml. +func (m *Manager) loadSettingOverrides(ctx context.Context) { + if v, err := m.store.GetSystemSetting(ctx, settingLocalEnabled); err == nil { + if b, err := strconv.ParseBool(v); err == nil { + m.config.Local.Enabled = b + } + } + if v, err := m.store.GetSystemSetting(ctx, settingAllowRegistration); err == nil { + if b, err := strconv.ParseBool(v); err == nil { + m.config.Local.AllowRegistration = b + } + } + if v, err := m.store.GetSystemSetting(ctx, settingAnonymousAccess); err == nil { + if b, err := strconv.ParseBool(v); err == nil { + m.config.AnonymousAccess = b + } + } + if v, err := m.store.GetSystemSetting(ctx, settingSessionTimeout); err == nil { + if i, err := strconv.Atoi(v); err == nil && i > 0 { + m.config.SessionTimeout = i + } + } +} + +// UpdateSettings updates mutable auth settings. Only non-nil parameters are applied. +func (m *Manager) UpdateSettings(ctx context.Context, localEnabled, allowReg, anonAccess *bool, sessionTimeout *int32) error { + // Validate session timeout + if sessionTimeout != nil && *sessionTimeout < 300 { + return ErrSessionTimeoutMin + } + + // Persist and apply each provided field + if localEnabled != nil { + if err := m.store.SetSystemSetting(ctx, settingLocalEnabled, strconv.FormatBool(*localEnabled)); err != nil { + return fmt.Errorf("failed to save local auth setting: %w", err) + } + m.config.Local.Enabled = *localEnabled + } + + if allowReg != nil { + if err := m.store.SetSystemSetting(ctx, settingAllowRegistration, strconv.FormatBool(*allowReg)); err != nil { + return fmt.Errorf("failed to save registration setting: %w", err) + } + m.config.Local.AllowRegistration = *allowReg + } + + if anonAccess != nil { + if err := m.store.SetSystemSetting(ctx, settingAnonymousAccess, strconv.FormatBool(*anonAccess)); err != nil { + return fmt.Errorf("failed to save anonymous access setting: %w", err) + } + m.config.AnonymousAccess = *anonAccess + } + + if sessionTimeout != nil { + if err := m.store.SetSystemSetting(ctx, settingSessionTimeout, strconv.Itoa(int(*sessionTimeout))); err != nil { + return fmt.Errorf("failed to save session timeout setting: %w", err) + } + m.config.SessionTimeout = int(*sessionTimeout) + } + + return nil +} + +func (m *Manager) generateJWT(userID, username string, roles []string, expiresAt time.Time) (string, error) { + claims := jwt.MapClaims{ + "user_id": userID, + "username": username, + "roles": roles, + "exp": expiresAt.Unix(), + "iat": time.Now().Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(m.jwtSecret) +} + +func (m *Manager) validateJWT(tokenString string) (jwt.MapClaims, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return m.jwtSecret, nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + if exp, ok := claims["exp"].(float64); ok { + if time.Now().Unix() > int64(exp) { + return nil, ErrSessionExpired + } + } + return claims, nil + } + + return nil, ErrInvalidToken +} + +func hashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +func checkPassword(hashedPassword, password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) + return err == nil +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go deleted file mode 100644 index e85a334..0000000 --- a/internal/auth/middleware.go +++ /dev/null @@ -1,163 +0,0 @@ -package auth - -import ( - "context" - "net/http" - "strings" - - "github.com/nickheyer/discopanel/internal/db" -) - -type contextKey string - -const ( - UserContextKey contextKey = "user" -) - -// Middleware provides authentication middleware for HTTP handlers -type Middleware struct { - authManager *Manager - store *db.Store -} - -// NewMiddleware creates a new authentication middleware -func NewMiddleware(authManager *Manager, store *db.Store) *Middleware { - return &Middleware{ - authManager: authManager, - store: store, - } -} - -// RequireAuth middleware checks if authentication is enabled and validates the user -func (m *Middleware) RequireAuth(requiredRole db.UserRole) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check if auth is enabled - authConfig, _, err := m.store.GetAuthConfig(r.Context()) - if err != nil { - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - // If auth is disabled, allow unrestricted access - if !authConfig.Enabled { - next.ServeHTTP(w, r) - return - } - - // Extract token from Authorization header - token := extractToken(r) - if token == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - // Validate session - user, err := m.authManager.ValidateSession(r.Context(), token) - if err != nil { - http.Error(w, "Invalid or expired token", http.StatusUnauthorized) - return - } - - // Check permission - if !CheckPermission(user, requiredRole) { - http.Error(w, "Insufficient permissions", http.StatusForbidden) - return - } - - // Add user to context - ctx := context.WithValue(r.Context(), UserContextKey, user) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } -} - -// OptionalAuth middleware checks authentication if present but doesn't require it -func (m *Middleware) OptionalAuth() func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check if auth is enabled - authConfig, _, err := m.store.GetAuthConfig(r.Context()) - if err != nil { - // Continue without auth on error - next.ServeHTTP(w, r) - return - } - - // If auth is disabled, continue without user - if !authConfig.Enabled { - next.ServeHTTP(w, r) - return - } - - // Extract token from Authorization header - token := extractToken(r) - if token != "" { - // Try to validate session - user, err := m.authManager.ValidateSession(r.Context(), token) - if err == nil && user != nil { - // Add user to context if valid - ctx := context.WithValue(r.Context(), UserContextKey, user) - r = r.WithContext(ctx) - } - } - - next.ServeHTTP(w, r) - }) - } -} - -// CheckAuthStatus returns a middleware that adds auth status to response headers -func (m *Middleware) CheckAuthStatus() func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check if auth is enabled - authConfig, _, err := m.store.GetAuthConfig(r.Context()) - if err != nil { - w.Header().Set("X-Auth-Enabled", "error") - } else if authConfig.Enabled { - w.Header().Set("X-Auth-Enabled", "true") - - // Check if this is the first user setup - userCount, _ := m.store.CountUsers(r.Context()) - if userCount == 0 { - w.Header().Set("X-Auth-First-User", "true") - } - } else { - w.Header().Set("X-Auth-Enabled", "false") - } - - next.ServeHTTP(w, r) - }) - } -} - -// extractToken extracts the JWT token from the Authorization header -func extractToken(r *http.Request) string { - // Check Authorization header - authHeader := r.Header.Get("Authorization") - if authHeader != "" { - // Bearer token - parts := strings.Split(authHeader, " ") - if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" { - return parts[1] - } - } - - // Check cookie as fallback - cookie, err := r.Cookie("auth_token") - if err == nil && cookie != nil { - return cookie.Value - } - - return "" -} - -// GetUserFromContext retrieves the user from the request context -func GetUserFromContext(ctx context.Context) *db.User { - user, ok := ctx.Value(UserContextKey).(*db.User) - if !ok { - return nil - } - return user -} \ No newline at end of file diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go new file mode 100644 index 0000000..c510f30 --- /dev/null +++ b/internal/auth/oidc.go @@ -0,0 +1,342 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/tls" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/google/uuid" + "github.com/nickheyer/discopanel/internal/config" + "github.com/nickheyer/discopanel/internal/db" + "github.com/nickheyer/discopanel/pkg/logger" + "golang.org/x/oauth2" +) + +type OIDCHandler struct { + manager *Manager + store *db.Store + config *config.OIDCConfig + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + oauth2Config *oauth2.Config + httpClient *http.Client + log *logger.Logger +} + +func NewOIDCHandler(manager *Manager, store *db.Store, cfg *config.OIDCConfig, log *logger.Logger) (*OIDCHandler, error) { + if !cfg.Enabled { + return &OIDCHandler{ + manager: manager, + store: store, + config: cfg, + log: log, + }, nil + } + + var httpClient *http.Client + if cfg.SkipTLSVerify { + httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + log.Warn("OIDC: TLS verification disabled") + } + + ctx := context.Background() + if httpClient != nil { + ctx = oidc.ClientContext(ctx, httpClient) + } + provider, err := oidc.NewProvider(ctx, cfg.IssuerURI) + if err != nil { + return nil, fmt.Errorf("failed to create OIDC provider: %w", err) + } + + verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}) + + scopes := cfg.Scopes + if len(scopes) == 0 { + scopes = []string{oidc.ScopeOpenID, "profile", "email"} + } + + oauth2Config := &oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURL: cfg.RedirectURL, + Endpoint: provider.Endpoint(), + Scopes: scopes, + } + + return &OIDCHandler{ + manager: manager, + store: store, + config: cfg, + provider: provider, + verifier: verifier, + oauth2Config: oauth2Config, + httpClient: httpClient, + log: log, + }, nil +} + +func (h *OIDCHandler) IsEnabled() bool { + return h.config.Enabled && h.provider != nil +} + +func (h *OIDCHandler) HandleLogin(w http.ResponseWriter, r *http.Request) { + if !h.IsEnabled() { + http.Error(w, "OIDC is not enabled", http.StatusBadRequest) + return + } + + state, err := generateState() + if err != nil { + http.Error(w, "Failed to generate state", http.StatusInternalServerError) + return + } + + // Store state in cookie + http.SetCookie(w, &http.Cookie{ + Name: "oidc_state", + Value: state, + Path: "/", + MaxAge: 300, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + http.Redirect(w, r, h.oauth2Config.AuthCodeURL(state), http.StatusFound) +} + +func (h *OIDCHandler) HandleCallback(w http.ResponseWriter, r *http.Request) { + if !h.IsEnabled() { + http.Error(w, "OIDC is not enabled", http.StatusBadRequest) + return + } + + // Verify state + stateCookie, err := r.Cookie("oidc_state") + if err != nil || stateCookie.Value != r.URL.Query().Get("state") { + http.Error(w, "Invalid state parameter", http.StatusBadRequest) + return + } + + // Clear state cookie + http.SetCookie(w, &http.Cookie{ + Name: "oidc_state", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + }) + + // Exchange code for token + ctx := r.Context() + if h.httpClient != nil { + ctx = oidc.ClientContext(ctx, h.httpClient) + } + oauth2Token, err := h.oauth2Config.Exchange(ctx, r.URL.Query().Get("code")) + if err != nil { + h.log.Error("OIDC: failed to exchange code for token: %v", err) + http.Error(w, "Failed to exchange code for token", http.StatusInternalServerError) + return + } + + // Extract ID token + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + h.log.Error("OIDC: no id_token in token response") + http.Error(w, "No id_token in response", http.StatusInternalServerError) + return + } + + // Verify ID token + idToken, err := h.verifier.Verify(ctx, rawIDToken) + if err != nil { + h.log.Error("OIDC: failed to verify ID token: %v", err) + http.Error(w, "Failed to verify ID token", http.StatusInternalServerError) + return + } + + // Extract claims from ID token + var claims map[string]any + if err := idToken.Claims(&claims); err != nil { + h.log.Error("OIDC: failed to parse claims: %v", err) + http.Error(w, "Failed to parse claims", http.StatusInternalServerError) + return + } + + // Fetch UserInfo - some oidc sets role/groups here + tokenSource := h.oauth2Config.TokenSource(ctx, oauth2Token) + userInfo, err := h.provider.UserInfo(ctx, tokenSource) + if err == nil { + var uiClaims map[string]any + if err := userInfo.Claims(&uiClaims); err == nil { + for k, v := range uiClaims { + if _, exists := claims[k]; !exists { + claims[k] = v + } + } + } + } + + // Extract user info from claims + sub := idToken.Subject + email, _ := claims["email"].(string) + username, _ := claims["preferred_username"].(string) + if username == "" { + username, _ = claims["name"].(string) + } + if username == "" { + username = email + } + if username == "" { + username = sub + } + + user, err := h.findOrCreateOIDCUser(ctx, sub, username, email) + if err != nil { + h.log.Error("OIDC: failed to find or create user (sub=%s, username=%s): %v", sub, username, err) + http.Error(w, "Failed to authenticate user", http.StatusInternalServerError) + return + } + + // Map OIDC claims to roles + h.mapClaimsToRoles(ctx, user.ID, claims) + + // Get user roles + roleNames, err := h.store.GetUserRoleNames(ctx, user.ID) + if err != nil { + roleNames = []string{} + } + + // Generate session token + expiresAt := time.Now().Add(time.Duration(h.manager.config.SessionTimeout) * time.Second) + token, err := h.manager.generateJWT(user.ID, user.Username, roleNames, expiresAt) + if err != nil { + h.log.Error("OIDC: failed to generate JWT: %v", err) + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + + // Create session + session := &db.Session{ + ID: uuid.New().String(), + UserID: user.ID, + Token: token, + ExpiresAt: expiresAt, + } + if err := h.store.CreateSession(ctx, session); err != nil { + h.log.Error("OIDC: failed to create session: %v", err) + http.Error(w, "Failed to create session", http.StatusInternalServerError) + return + } + + h.log.Info("OIDC: user %s authenticated successfully", user.Username) + + // Redirect to frontend with token in query param + http.Redirect(w, r, fmt.Sprintf("/login?token=%s", token), http.StatusFound) +} + +// findOrCreateOIDCUser looks up a user by OIDC subject (returning user), +// or creates a new OIDC user. Local users with the same username are not +// affected — the composite unique constraint (username, auth_provider) +// allows both to coexist. +func (h *OIDCHandler) findOrCreateOIDCUser(ctx context.Context, sub, username, email string) (*db.User, error) { + // Step 1: try to find by OIDC subject (returning user) + if user, err := h.store.GetUserByOIDCSubject(ctx, sub); err == nil { + if !user.IsActive { + return nil, ErrUserNotActive + } + // Update email/last login on returning users + if email != "" { + user.Email = &email + } + now := time.Now() + user.LastLogin = &now + _ = h.store.UpdateUser(ctx, user) + return user, nil + } + + // Step 2: create a new OIDC user + var emailPtr *string + if email != "" { + emailPtr = &email + } + user := &db.User{ + ID: uuid.New().String(), + Username: username, + Email: emailPtr, + AuthProvider: "oidc", + OIDCSubject: sub, + OIDCIssuer: h.config.IssuerURI, + IsActive: true, + } + if err := h.store.CreateUser(ctx, user); err != nil { + return nil, fmt.Errorf("failed to create OIDC user: %w", err) + } + + // Assign default roles to new user + defaultRoles, _ := h.store.GetDefaultRoles(ctx) + for _, role := range defaultRoles { + _ = h.store.AssignRole(ctx, user.ID, role.Name, "oidc") + } + + h.log.Info("OIDC: created new user %s", user.Username) + return user, nil +} + +func (h *OIDCHandler) mapClaimsToRoles(ctx context.Context, userID string, claims map[string]any) { + if h.config.RoleClaim == "" { + return + } + + // Extract groups/roles from claims + var claimValues []string + claimValue, ok := claims[h.config.RoleClaim] + if !ok { + h.log.Warn("OIDC: role claim %q not found in token claims", h.config.RoleClaim) + return + } + switch v := claimValue.(type) { + case []any: + for _, item := range v { + if s, ok := item.(string); ok { + claimValues = append(claimValues, s) + } + } + case string: + // Try JSON array + var arr []string + if err := json.Unmarshal([]byte(v), &arr); err == nil { + claimValues = arr + } else { + claimValues = []string{v} + } + } + + // Map OIDC claims to local roles + use role mappings if provided in cfg + for _, claimVal := range claimValues { + if len(h.config.RoleMapping) > 0 { + if localRole, ok := h.config.RoleMapping[claimVal]; ok { + _ = h.store.AssignRole(ctx, userID, localRole, "oidc") + } + } else { + _ = h.store.AssignRole(ctx, userID, claimVal, "oidc") + } + } +} + +func generateState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} diff --git a/internal/auth/recovery.go b/internal/auth/recovery.go deleted file mode 100644 index a023eb5..0000000 --- a/internal/auth/recovery.go +++ /dev/null @@ -1,84 +0,0 @@ -package auth - -import ( - "fmt" - "os" - "path/filepath" -) - -// GetRecoveryKeyPath returns the path where the recovery key should be stored -func GetRecoveryKeyPath() (string, error) { - // Get the data directory from environment or use default - dataDir := os.Getenv("DISCOPANEL_DATA_DIR") - if dataDir == "" { - // Use current working directory - cwd, err := os.Getwd() - if err != nil { - return "", err - } - dataDir = filepath.Join(cwd, "data") - } - - // Ensure directory exists - if err := os.MkdirAll(dataDir, 0700); err != nil { - return "", err - } - - return filepath.Join(dataDir, ".recovery_key"), nil -} - -// SaveRecoveryKeyToFile saves the recovery key to a secure file -func SaveRecoveryKeyToFile(key string) error { - path, err := GetRecoveryKeyPath() - if err != nil { - return err - } - - // Write key to file with restricted permissions (owner read only) - content := "DiscoPanel Recovery Key\n========================\n\n" - content += fmt.Sprintf("Key: %s\n\n", key) - content += "IMPORTANT: Keep this key secure! It can be used to reset any user password.\n" - content += "Store it in a safe place outside of this server.\n\n" - content += "To use this key for password recovery:\n" - content += "1. Access the login page\n" - content += "2. Click 'Forgot Password'\n" - content += "3. Enter your username and this recovery key\n" - content += "4. Set a new password\n" - - return os.WriteFile(path, []byte(content), 0400) -} - -// ReadRecoveryKeyFromFile reads the recovery key from file (for display purposes only) -func ReadRecoveryKeyFromFile() (string, error) { - path, err := GetRecoveryKeyPath() - if err != nil { - return "", err - } - - data, err := os.ReadFile(path) - if err != nil { - return "", err - } - - // Parse the key from the file content - // This is a simple implementation - in production, you might want more robust parsing - lines := string(data) - keyPrefix := "Key: " - start := 0 - for i := 0; i < len(lines); i++ { - if i+len(keyPrefix) <= len(lines) && lines[i:i+len(keyPrefix)] == keyPrefix { - start = i + len(keyPrefix) - break - } - } - - if start > 0 { - end := start - for end < len(lines) && lines[end] != '\n' { - end++ - } - return lines[start:end], nil - } - - return "", fmt.Errorf("recovery key not found in file") -} diff --git a/internal/config/config.go b/internal/config/config.go index a34dc4f..954e2da 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,32 @@ type Config struct { Minecraft MinecraftConfig `mapstructure:"minecraft" json:"minecraft"` Logging LoggingConfig `mapstructure:"logging" json:"logging"` Upload UploadConfig `mapstructure:"upload" json:"upload"` + Auth AuthConfig `mapstructure:"auth" json:"auth"` +} + +type AuthConfig struct { + SessionTimeout int `mapstructure:"session_timeout" json:"session_timeout"` + AnonymousAccess bool `mapstructure:"anonymous_access" json:"anonymous_access"` + JWTSecret string `mapstructure:"jwt_secret" json:"jwt_secret"` + OIDC OIDCConfig `mapstructure:"oidc" json:"oidc"` + Local LocalConfig `mapstructure:"local" json:"local"` +} + +type OIDCConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + IssuerURI string `mapstructure:"issuer_uri" json:"issuer_uri"` + ClientID string `mapstructure:"client_id" json:"client_id"` + ClientSecret string `mapstructure:"client_secret" json:"client_secret"` + RedirectURL string `mapstructure:"redirect_url" json:"redirect_url"` + Scopes []string `mapstructure:"scopes" json:"scopes"` + RoleClaim string `mapstructure:"role_claim" json:"role_claim"` + RoleMapping map[string]string `mapstructure:"role_mapping" json:"role_mapping"` + SkipTLSVerify bool `mapstructure:"skip_tls_verify" json:"skip_tls_verify"` +} + +type LocalConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + AllowRegistration bool `mapstructure:"allow_registration" json:"allow_registration"` } type ServerConfig struct { @@ -189,6 +215,21 @@ func setDefaults(v *viper.Viper) { v.SetDefault("logging.max_age", 30) // 30 days v.SetDefault("logging.compress", true) // compress rotated + // Auth defaults + v.SetDefault("auth.session_timeout", 86400) + v.SetDefault("auth.anonymous_access", false) + v.SetDefault("auth.jwt_secret", "") + v.SetDefault("auth.oidc.enabled", false) + v.SetDefault("auth.oidc.issuer_uri", "") + v.SetDefault("auth.oidc.client_id", "") + v.SetDefault("auth.oidc.client_secret", "") + v.SetDefault("auth.oidc.redirect_url", "") + v.SetDefault("auth.oidc.scopes", []string{"openid", "profile", "email", "groups"}) + v.SetDefault("auth.oidc.role_claim", "groups") + v.SetDefault("auth.oidc.skip_tls_verify", false) + v.SetDefault("auth.local.enabled", true) + v.SetDefault("auth.local.allow_registration", false) + // Upload defaults v.SetDefault("upload.session_ttl", 240) // 4 hours (in minutes) v.SetDefault("upload.default_chunk_size", 5*1024*1024) // 5MB diff --git a/internal/db/models.go b/internal/db/models.go index f4191ae..5cef598 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -386,40 +386,45 @@ type ProxyListener struct { UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } -// UserRole defines the role of a user in the system -type UserRole string - -const ( - RoleAdmin UserRole = "admin" // Full access to all features - RoleEditor UserRole = "editor" // Can manage servers but not system settings - RoleViewer UserRole = "viewer" // Read-only access -) - // User represents a user account type User struct { ID string `json:"id" gorm:"primaryKey"` - Username string `json:"username" gorm:"not null;uniqueIndex"` - Email *string `json:"email" gorm:"uniqueIndex"` // Pointer allows NULL, unique only on non-NULL - PasswordHash string `json:"-" gorm:"not null;column:password_hash"` - Role UserRole `json:"role" gorm:"not null;default:'viewer'"` + Username string `json:"username" gorm:"not null;uniqueIndex:idx_user_provider"` + Email *string `json:"email" gorm:"index"` + PasswordHash string `json:"-" gorm:"column:password_hash"` + AuthProvider string `json:"auth_provider" gorm:"not null;default:'local';uniqueIndex:idx_user_provider"` + OIDCSubject string `json:"oidc_subject" gorm:"column:oidc_subject;uniqueIndex:idx_oidc_identity,where:oidc_subject != ''"` + OIDCIssuer string `json:"oidc_issuer" gorm:"column:oidc_issuer;uniqueIndex:idx_oidc_identity,where:oidc_subject != ''"` IsActive bool `json:"is_active" gorm:"not null;default:true"` LastLogin *time.Time `json:"last_login" gorm:"column:last_login"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } -// AuthConfig stores authentication configuration -type AuthConfig struct { - ID string `json:"id" gorm:"primaryKey"` - Enabled bool `json:"enabled" gorm:"not null;default:false"` - RecoveryKey string `json:"-" gorm:"column:recovery_key"` // Secret key for account recovery - RecoveryKeyHash string `json:"-" gorm:"column:recovery_key_hash"` // Hashed version for verification - JWTSecret string `json:"-" gorm:"column:jwt_secret"` // Secret for JWT signing - SessionTimeout int `json:"session_timeout" gorm:"default:86400"` // Session timeout in seconds (default 24h) - RequireEmailVerify bool `json:"require_email_verify" gorm:"default:false"` - AllowRegistration bool `json:"allow_registration" gorm:"default:false"` // Allow new user registration - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +// Role represents a role in the RBAC system +type Role struct { + ID string `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"not null;uniqueIndex"` + Description string `json:"description"` + IsSystem bool `json:"is_system" gorm:"not null;default:false"` + IsDefault bool `json:"is_default" gorm:"not null;default:false"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// UserRole links users to roles +type UserRole struct { + ID string `json:"id" gorm:"primaryKey"` + UserID string `json:"user_id" gorm:"not null;index;column:user_id"` + RoleName string `json:"role_name" gorm:"not null;index;column:role_name"` + Source string `json:"source" gorm:"not null;default:'local'"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` +} + +// SystemSetting stores key-value pairs for internal system configuration. +type SystemSetting struct { + Key string `gorm:"primaryKey"` + Value string `gorm:"not null"` } // Session represents an active user session @@ -428,8 +433,6 @@ type Session struct { UserID string `json:"user_id" gorm:"not null;index;column:user_id"` Token string `json:"-" gorm:"not null;uniqueIndex"` ExpiresAt time.Time `json:"expires_at" gorm:"not null;index"` - IPAddress string `json:"ip_address"` - UserAgent string `json:"user_agent"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` User *User `json:"user,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` } diff --git a/internal/db/store.go b/internal/db/store.go index 091d375..ce56886 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -62,6 +62,10 @@ func NewSQLiteStore(dbPath string, config ...DBConfig) (*Store, error) { return store, nil } +func (s *Store) DB() *gorm.DB { + return s.db +} + func (s *Store) Close() error { sqlDB, err := s.db.DB() if err != nil { @@ -82,12 +86,14 @@ func (s *Store) Migrate() error { &ProxyConfig{}, &ProxyListener{}, &User{}, - &AuthConfig{}, + &Role{}, + &UserRole{}, &Session{}, &ScheduledTask{}, &TaskExecution{}, &ModuleTemplate{}, &Module{}, + &SystemSetting{}, ) if err != nil { return fmt.Errorf("failed to auto-migrate: %w", err) @@ -101,6 +107,24 @@ func (s *Store) Migrate() error { return err } + // Migrate User indexes: composite unique on (username, auth_provider), drop old single-column unique + s.db.Exec("DROP INDEX IF EXISTS idx_users_username") + s.db.Exec("DROP INDEX IF EXISTS idx_users_email") + if err := s.db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider ON users(username, auth_provider)").Error; err != nil { + return err + } + if err := s.db.Exec("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)").Error; err != nil { + return err + } + if err := s.db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_oidc_identity ON users(oidc_subject, oidc_issuer) WHERE oidc_subject != ''").Error; err != nil { + return err + } + + // Seed system roles + if err := s.SeedSystemRoles(); err != nil { + return fmt.Errorf("failed to seed system roles: %w", err) + } + return nil } @@ -727,9 +751,9 @@ func (s *Store) GetUser(ctx context.Context, id string) (*User, error) { return &user, nil } -func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) { +func (s *Store) GetUserByUsernameAndProvider(ctx context.Context, username, provider string) (*User, error) { var user User - err := s.db.WithContext(ctx).First(&user, "username = ?", username).Error + err := s.db.WithContext(ctx).First(&user, "username = ? AND auth_provider = ?", username, provider).Error if err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("user not found") @@ -739,9 +763,9 @@ func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, return &user, nil } -func (s *Store) GetUserByEmail(ctx context.Context, email string) (*User, error) { +func (s *Store) GetUserByOIDCSubject(ctx context.Context, subject string) (*User, error) { var user User - err := s.db.WithContext(ctx).First(&user, "email = ?", email).Error + err := s.db.WithContext(ctx).First(&user, "oidc_subject = ?", subject).Error if err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("user not found") @@ -763,11 +787,12 @@ func (s *Store) UpdateUser(ctx context.Context, user *User) error { func (s *Store) DeleteUser(ctx context.Context, id string) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // Delete sessions if err := tx.Where("user_id = ?", id).Delete(&Session{}).Error; err != nil { return err } - // Delete user + if err := tx.Where("user_id = ?", id).Delete(&UserRole{}).Error; err != nil { + return err + } return tx.Delete(&User{}, "id = ?", id).Error }) } @@ -778,31 +803,108 @@ func (s *Store) CountUsers(ctx context.Context) (int64, error) { return count, err } -// AuthConfig operations -func (s *Store) GetAuthConfig(ctx context.Context) (*AuthConfig, bool, error) { - var config AuthConfig - err := s.db.WithContext(ctx).First(&config).Error +// Role operations +func (s *Store) SeedSystemRoles() error { + roles := []Role{ + {ID: "role-admin", Name: "admin", Description: "Full system access", IsSystem: true}, + {ID: "role-user", Name: "user", Description: "Standard user access", IsSystem: true, IsDefault: true}, + {ID: "role-anonymous", Name: "anonymous", Description: "Unauthenticated user access", IsSystem: true}, + } + for _, role := range roles { + var existing Role + if err := s.db.Where("name = ?", role.Name).First(&existing).Error; err == gorm.ErrRecordNotFound { + if err := s.db.Create(&role).Error; err != nil { + return err + } + } + } + return nil +} + +func (s *Store) CreateRole(ctx context.Context, role *Role) error { + if role.ID == "" { + role.ID = uuid.New().String() + } + return s.db.WithContext(ctx).Create(role).Error +} + +func (s *Store) GetRole(ctx context.Context, id string) (*Role, error) { + var role Role + err := s.db.WithContext(ctx).First(&role, "id = ?", id).Error if err != nil { if err == gorm.ErrRecordNotFound { - // Return default config if none exists - return &AuthConfig{ - ID: "default", - Enabled: false, - SessionTimeout: 86400, // 24 hours - RequireEmailVerify: false, - AllowRegistration: false, - }, true, nil + return nil, fmt.Errorf("role not found") } - return nil, false, err + return nil, err } - return &config, false, nil + return &role, nil } -func (s *Store) SaveAuthConfig(ctx context.Context, config *AuthConfig) error { - if config.ID == "" { - config.ID = "default" +func (s *Store) ListRoles(ctx context.Context) ([]*Role, error) { + var roles []*Role + err := s.db.WithContext(ctx).Order("is_system DESC, name ASC").Find(&roles).Error + return roles, err +} + +func (s *Store) GetDefaultRoles(ctx context.Context) ([]*Role, error) { + var roles []*Role + err := s.db.WithContext(ctx).Where("is_default = ?", true).Find(&roles).Error + return roles, err +} + +func (s *Store) UpdateRole(ctx context.Context, role *Role) error { + return s.db.WithContext(ctx).Save(role).Error +} + +func (s *Store) DeleteRole(ctx context.Context, id string) error { + var role Role + if err := s.db.WithContext(ctx).First(&role, "id = ?", id).Error; err != nil { + return err } - return s.db.WithContext(ctx).Save(config).Error + if role.IsSystem { + return fmt.Errorf("cannot delete system role") + } + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("role_name = ?", role.Name).Delete(&UserRole{}).Error; err != nil { + return err + } + return tx.Delete(&Role{}, "id = ?", id).Error + }) +} + +// UserRole operations +func (s *Store) AssignRole(ctx context.Context, userID, roleName, source string) error { + var existing UserRole + err := s.db.WithContext(ctx).Where("user_id = ? AND role_name = ?", userID, roleName).First(&existing).Error + if err == nil { + return nil // Already assigned + } + ur := &UserRole{ + ID: uuid.New().String(), + UserID: userID, + RoleName: roleName, + Source: source, + } + return s.db.WithContext(ctx).Create(ur).Error +} + +func (s *Store) UnassignRole(ctx context.Context, userID, roleName string) error { + return s.db.WithContext(ctx).Where("user_id = ? AND role_name = ?", userID, roleName).Delete(&UserRole{}).Error +} + +func (s *Store) GetUserRoleNames(ctx context.Context, userID string) ([]string, error) { + var names []string + err := s.db.WithContext(ctx). + Model(&UserRole{}). + Select("user_roles.role_name"). + Joins("LEFT JOIN roles ON roles.name = user_roles.role_name"). + Where("user_roles.user_id = ?", userID). + Order("roles.is_system DESC, roles.name ASC"). + Pluck("user_roles.role_name", &names).Error + if err != nil { + return nil, err + } + return names, nil } // Session operations @@ -829,14 +931,31 @@ func (s *Store) DeleteSession(ctx context.Context, token string) error { return s.db.WithContext(ctx).Where("token = ?", token).Delete(&Session{}).Error } -func (s *Store) DeleteUserSessions(ctx context.Context, userID string) error { - return s.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&Session{}).Error -} - func (s *Store) CleanExpiredSessions(ctx context.Context) error { return s.db.WithContext(ctx).Where("expires_at < ?", time.Now()).Delete(&Session{}).Error } +// CleanAllSessions deletes all sessions (used when JWT secret changes). +func (s *Store) CleanAllSessions(ctx context.Context) error { + return s.db.WithContext(ctx).Where("1 = 1").Delete(&Session{}).Error +} + +// SystemSetting operations + +func (s *Store) GetSystemSetting(ctx context.Context, key string) (string, error) { + var setting SystemSetting + err := s.db.WithContext(ctx).First(&setting, "key = ?", key).Error + if err != nil { + return "", err + } + return setting.Value, nil +} + +func (s *Store) SetSystemSetting(ctx context.Context, key, value string) error { + setting := SystemSetting{Key: key, Value: value} + return s.db.WithContext(ctx).Save(&setting).Error +} + // ScheduledTask operations func (s *Store) CreateScheduledTask(ctx context.Context, task *ScheduledTask) error { if task.ID == "" { diff --git a/internal/rbac/mapping.go b/internal/rbac/mapping.go new file mode 100644 index 0000000..63eff3a --- /dev/null +++ b/internal/rbac/mapping.go @@ -0,0 +1,167 @@ +package rbac + +// ProcedurePermission maps an RPC procedure to a resource and action. +type ProcedurePermission struct { + Resource string + Action string + ObjectIDField string // Protobuf field name to extract for per-object RBAC (empty = "*") +} + +// PublicProcedures lists RPC procedures that require no authentication. +var PublicProcedures = map[string]bool{ + "/discopanel.v1.AuthService/GetAuthStatus": true, + "/discopanel.v1.AuthService/Login": true, + "/discopanel.v1.AuthService/Register": true, + "/discopanel.v1.AuthService/GetOIDCLoginURL": true, +} + +// AuthenticatedOnlyProcedures lists RPC procedures that require authentication +// but no specific resource permission. +var AuthenticatedOnlyProcedures = map[string]bool{ + // AuthService - authenticated user operations + "/discopanel.v1.AuthService/GetCurrentUser": true, + "/discopanel.v1.AuthService/Logout": true, + "/discopanel.v1.AuthService/ChangePassword": true, + + // MinecraftService - reference data, no resource ownership + "/discopanel.v1.MinecraftService/GetMinecraftVersions": true, + "/discopanel.v1.MinecraftService/GetModLoaders": true, + "/discopanel.v1.MinecraftService/GetDockerImages": true, +} + +// ProcedurePermissions maps each RPC procedure path to the resource and action +// required to invoke it, plus an optional ObjectIDField for per-object scoping. +var ProcedurePermissions = map[string]ProcedurePermission{ + // ── ServerService ────────────────────────────────────────────────── + "/discopanel.v1.ServerService/ListServers": {Resource: ResourceServers, Action: ActionRead}, + "/discopanel.v1.ServerService/GetServer": {Resource: ResourceServers, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/GetServerLogs": {Resource: ResourceServers, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/ClearServerLogs": {Resource: ResourceServers, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/GetNextAvailablePort": {Resource: ResourceServers, Action: ActionRead}, + "/discopanel.v1.ServerService/CreateServer": {Resource: ResourceServers, Action: ActionCreate}, + "/discopanel.v1.ServerService/UpdateServer": {Resource: ResourceServers, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/DeleteServer": {Resource: ResourceServers, Action: ActionDelete, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/StartServer": {Resource: ResourceServers, Action: ActionStart, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/StopServer": {Resource: ResourceServers, Action: ActionStop, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/RestartServer": {Resource: ResourceServers, Action: ActionRestart, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/RecreateServer": {Resource: ResourceServers, Action: ActionRestart, ObjectIDField: "id"}, + "/discopanel.v1.ServerService/SendCommand": {Resource: ResourceServers, Action: ActionCommand, ObjectIDField: "id"}, + + // ── AuthService (admin) ─────────────────────────────────────────── + "/discopanel.v1.AuthService/GetAuthConfig": {Resource: ResourceSettings, Action: ActionRead}, + "/discopanel.v1.AuthService/UpdateAuthSettings": {Resource: ResourceSettings, Action: ActionUpdate}, + + // ── ConfigService ────────────────────────────────────────────────── + "/discopanel.v1.ConfigService/GetServerConfig": {Resource: ResourceServerConfig, Action: ActionRead, ObjectIDField: "server_id"}, + "/discopanel.v1.ConfigService/UpdateServerConfig": {Resource: ResourceServerConfig, Action: ActionUpdate, ObjectIDField: "server_id"}, + "/discopanel.v1.ConfigService/GetGlobalSettings": {Resource: ResourceSettings, Action: ActionRead}, + "/discopanel.v1.ConfigService/UpdateGlobalSettings": {Resource: ResourceSettings, Action: ActionUpdate}, + + // ── FileService ──────────────────────────────────────────────────── + "/discopanel.v1.FileService/ListFiles": {Resource: ResourceFiles, Action: ActionRead, ObjectIDField: "server_id"}, + "/discopanel.v1.FileService/GetFile": {Resource: ResourceFiles, Action: ActionRead, ObjectIDField: "server_id"}, + "/discopanel.v1.FileService/SaveUploadedFile": {Resource: ResourceFiles, Action: ActionCreate, ObjectIDField: "server_id"}, + "/discopanel.v1.FileService/UpdateFile": {Resource: ResourceFiles, Action: ActionUpdate, ObjectIDField: "server_id"}, + "/discopanel.v1.FileService/DeleteFile": {Resource: ResourceFiles, Action: ActionDelete, ObjectIDField: "server_id"}, + "/discopanel.v1.FileService/RenameFile": {Resource: ResourceFiles, Action: ActionUpdate, ObjectIDField: "server_id"}, + "/discopanel.v1.FileService/ExtractArchive": {Resource: ResourceFiles, Action: ActionUpdate, ObjectIDField: "server_id"}, + + // ── ModService ───────────────────────────────────────────────────── + "/discopanel.v1.ModService/ListMods": {Resource: ResourceMods, Action: ActionRead, ObjectIDField: "server_id"}, + "/discopanel.v1.ModService/GetMod": {Resource: ResourceMods, Action: ActionRead, ObjectIDField: "server_id"}, + "/discopanel.v1.ModService/ImportUploadedMod": {Resource: ResourceMods, Action: ActionCreate, ObjectIDField: "server_id"}, + "/discopanel.v1.ModService/UpdateMod": {Resource: ResourceMods, Action: ActionUpdate, ObjectIDField: "server_id"}, + "/discopanel.v1.ModService/DeleteMod": {Resource: ResourceMods, Action: ActionDelete, ObjectIDField: "server_id"}, + + // ── ModpackService ───────────────────────────────────────────────── + "/discopanel.v1.ModpackService/SearchModpacks": {Resource: ResourceModpacks, Action: ActionRead}, + "/discopanel.v1.ModpackService/GetModpack": {Resource: ResourceModpacks, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ModpackService/GetModpackBySlug": {Resource: ResourceModpacks, Action: ActionRead}, + "/discopanel.v1.ModpackService/GetModpackByURL": {Resource: ResourceModpacks, Action: ActionRead}, + "/discopanel.v1.ModpackService/SyncModpacks": {Resource: ResourceModpacks, Action: ActionCreate}, + "/discopanel.v1.ModpackService/ImportUploadedModpack": {Resource: ResourceModpacks, Action: ActionCreate}, + "/discopanel.v1.ModpackService/DeleteModpack": {Resource: ResourceModpacks, Action: ActionDelete, ObjectIDField: "id"}, + "/discopanel.v1.ModpackService/ToggleFavorite": {Resource: ResourceModpacks, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.ModpackService/ListFavorites": {Resource: ResourceModpacks, Action: ActionRead}, + "/discopanel.v1.ModpackService/GetIndexerStatus": {Resource: ResourceModpacks, Action: ActionRead}, + "/discopanel.v1.ModpackService/GetModpackConfig": {Resource: ResourceModpacks, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ModpackService/GetModpackFiles": {Resource: ResourceModpacks, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ModpackService/GetModpackVersions": {Resource: ResourceModpacks, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ModpackService/SyncModpackFiles": {Resource: ResourceModpacks, Action: ActionUpdate, ObjectIDField: "id"}, + + // ── ModuleService ────────────────────────────────────────────────── + "/discopanel.v1.ModuleService/ListModuleTemplates": {Resource: ResourceModuleTemplates, Action: ActionRead}, + "/discopanel.v1.ModuleService/GetModuleTemplate": {Resource: ResourceModuleTemplates, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/CreateModuleTemplate": {Resource: ResourceModuleTemplates, Action: ActionCreate}, + "/discopanel.v1.ModuleService/UpdateModuleTemplate": {Resource: ResourceModuleTemplates, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/DeleteModuleTemplate": {Resource: ResourceModuleTemplates, Action: ActionDelete, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/ListModules": {Resource: ResourceModules, Action: ActionRead}, + "/discopanel.v1.ModuleService/GetModule": {Resource: ResourceModules, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/CreateModule": {Resource: ResourceModules, Action: ActionCreate, ObjectIDField: "server_id"}, + "/discopanel.v1.ModuleService/UpdateModule": {Resource: ResourceModules, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/DeleteModule": {Resource: ResourceModules, Action: ActionDelete, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/StartModule": {Resource: ResourceModules, Action: ActionStart, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/StopModule": {Resource: ResourceModules, Action: ActionStop, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/RestartModule": {Resource: ResourceModules, Action: ActionRestart, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/RecreateModule": {Resource: ResourceModules, Action: ActionRestart, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/GetModuleLogs": {Resource: ResourceModules, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.ModuleService/GetNextAvailableModulePort": {Resource: ResourceModules, Action: ActionRead}, + "/discopanel.v1.ModuleService/GetAvailableAliases": {Resource: ResourceModules, Action: ActionRead}, + "/discopanel.v1.ModuleService/GetResolvedAliases": {Resource: ResourceModules, Action: ActionRead}, + + // ── ProxyService ─────────────────────────────────────────────────── + "/discopanel.v1.ProxyService/GetProxyRoutes": {Resource: ResourceProxy, Action: ActionRead}, + "/discopanel.v1.ProxyService/GetProxyStatus": {Resource: ResourceProxy, Action: ActionRead}, + "/discopanel.v1.ProxyService/UpdateProxyConfig": {Resource: ResourceProxy, Action: ActionUpdate}, + "/discopanel.v1.ProxyService/GetProxyListeners": {Resource: ResourceProxy, Action: ActionRead}, + "/discopanel.v1.ProxyService/CreateProxyListener": {Resource: ResourceProxy, Action: ActionCreate}, + "/discopanel.v1.ProxyService/UpdateProxyListener": {Resource: ResourceProxy, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.ProxyService/DeleteProxyListener": {Resource: ResourceProxy, Action: ActionDelete, ObjectIDField: "id"}, + "/discopanel.v1.ProxyService/GetServerRouting": {Resource: ResourceProxy, Action: ActionRead, ObjectIDField: "server_id"}, + "/discopanel.v1.ProxyService/UpdateServerRouting": {Resource: ResourceProxy, Action: ActionUpdate, ObjectIDField: "server_id"}, + + // ── TaskService ──────────────────────────────────────────────────── + "/discopanel.v1.TaskService/ListTasks": {Resource: ResourceTasks, Action: ActionRead, ObjectIDField: "server_id"}, + "/discopanel.v1.TaskService/GetTask": {Resource: ResourceTasks, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.TaskService/CreateTask": {Resource: ResourceTasks, Action: ActionCreate, ObjectIDField: "server_id"}, + "/discopanel.v1.TaskService/UpdateTask": {Resource: ResourceTasks, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.TaskService/DeleteTask": {Resource: ResourceTasks, Action: ActionDelete, ObjectIDField: "id"}, + "/discopanel.v1.TaskService/ToggleTask": {Resource: ResourceTasks, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.TaskService/TriggerTask": {Resource: ResourceTasks, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.TaskService/ListTaskExecutions": {Resource: ResourceTasks, Action: ActionRead, ObjectIDField: "task_id"}, + "/discopanel.v1.TaskService/ListServerExecutions": {Resource: ResourceTasks, Action: ActionRead, ObjectIDField: "server_id"}, + "/discopanel.v1.TaskService/GetTaskExecution": {Resource: ResourceTasks, Action: ActionRead, ObjectIDField: "id"}, + "/discopanel.v1.TaskService/CancelExecution": {Resource: ResourceTasks, Action: ActionUpdate, ObjectIDField: "id"}, + "/discopanel.v1.TaskService/GetSchedulerStatus": {Resource: ResourceTasks, Action: ActionRead}, + + // ── UserService ──────────────────────────────────────────────────── + "/discopanel.v1.UserService/ListUsers": {Resource: ResourceUsers, Action: ActionRead}, + "/discopanel.v1.UserService/GetUser": {Resource: ResourceUsers, Action: ActionRead}, + "/discopanel.v1.UserService/CreateUser": {Resource: ResourceUsers, Action: ActionCreate}, + "/discopanel.v1.UserService/UpdateUser": {Resource: ResourceUsers, Action: ActionUpdate}, + "/discopanel.v1.UserService/DeleteUser": {Resource: ResourceUsers, Action: ActionDelete}, + + // ── RoleService ──────────────────────────────────────────────────── + "/discopanel.v1.RoleService/ListRoles": {Resource: ResourceRoles, Action: ActionRead}, + "/discopanel.v1.RoleService/GetRole": {Resource: ResourceRoles, Action: ActionRead}, + "/discopanel.v1.RoleService/CreateRole": {Resource: ResourceRoles, Action: ActionCreate}, + "/discopanel.v1.RoleService/UpdateRole": {Resource: ResourceRoles, Action: ActionUpdate}, + "/discopanel.v1.RoleService/DeleteRole": {Resource: ResourceRoles, Action: ActionDelete}, + "/discopanel.v1.RoleService/GetPermissionMatrix": {Resource: ResourceRoles, Action: ActionRead}, + "/discopanel.v1.RoleService/UpdatePermissions": {Resource: ResourceRoles, Action: ActionUpdate}, + "/discopanel.v1.RoleService/AssignRole": {Resource: ResourceRoles, Action: ActionCreate}, + "/discopanel.v1.RoleService/UnassignRole": {Resource: ResourceRoles, Action: ActionDelete}, + "/discopanel.v1.RoleService/GetUserRoles": {Resource: ResourceRoles, Action: ActionRead}, + + // ── SupportService ───────────────────────────────────────────────── + "/discopanel.v1.SupportService/GenerateSupportBundle": {Resource: ResourceSupport, Action: ActionCreate}, + "/discopanel.v1.SupportService/DownloadSupportBundle": {Resource: ResourceSupport, Action: ActionRead}, + "/discopanel.v1.SupportService/UploadSupportBundle": {Resource: ResourceSupport, Action: ActionCreate}, + "/discopanel.v1.SupportService/GetApplicationLogs": {Resource: ResourceSupport, Action: ActionRead}, + + // ── UploadService ────────────────────────────────────────────────── + "/discopanel.v1.UploadService/GetUploadStatus": {Resource: ResourceUploads, Action: ActionRead}, + "/discopanel.v1.UploadService/InitUpload": {Resource: ResourceUploads, Action: ActionCreate}, + "/discopanel.v1.UploadService/UploadChunk": {Resource: ResourceUploads, Action: ActionCreate}, + "/discopanel.v1.UploadService/CancelUpload": {Resource: ResourceUploads, Action: ActionDelete}, +} diff --git a/internal/rbac/rbac.go b/internal/rbac/rbac.go new file mode 100644 index 0000000..88e7e8c --- /dev/null +++ b/internal/rbac/rbac.go @@ -0,0 +1,195 @@ +package rbac + +import ( + "fmt" + "strings" + + "github.com/casbin/casbin/v3" + "github.com/casbin/casbin/v3/model" + gormadapter "github.com/casbin/gorm-adapter/v3" + "gorm.io/gorm" +) + +// Permission represents a single resource/action/object permission tuple. +type Permission struct { + Resource string + Action string + ObjectID string +} + +// Enforcer wraps a Casbin enforcer with convenience methods for RBAC. +type Enforcer struct { + enforcer *casbin.Enforcer +} + +// NewEnforcer creates a new Casbin RBAC enforcer backed by the given GORM database. +func NewEnforcer(db *gorm.DB) (*Enforcer, error) { + adapter, err := gormadapter.NewAdapterByDB(db) + if err != nil { + return nil, fmt.Errorf("failed to create casbin adapter: %w", err) + } + + // RBAC model with resource/action/object_id + m, err := model.NewModelFromString(` +[request_definition] +r = sub, res, act, obj + +[policy_definition] +p = sub, res, act, obj + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && (p.res == "*" || r.res == p.res) && (p.act == "*" || r.act == p.act) && (p.obj == "*" || r.obj == p.obj) +`) + if err != nil { + return nil, fmt.Errorf("failed to create casbin model: %w", err) + } + + e, err := casbin.NewEnforcer(m, adapter) + if err != nil { + return nil, fmt.Errorf("failed to create casbin enforcer: %w", err) + } + + if err := e.LoadPolicy(); err != nil { + return nil, fmt.Errorf("failed to load casbin policy: %w", err) + } + + return &Enforcer{enforcer: e}, nil +} + +// SeedDefaultPolicies ensures default roles have their base permissions. +// When anonymousEnabled is true, the anonymous role receives read-only +// access to common resources. When false, all anonymous policies are removed. +func (e *Enforcer) SeedDefaultPolicies(anonymousEnabled bool) error { + policies := [][]string{ + {"admin", "*", "*", "*"}, + {"user", ResourceServers, ActionRead, "*"}, + {"user", ResourceServers, ActionStart, "*"}, + {"user", ResourceServers, ActionStop, "*"}, + {"user", ResourceServers, ActionRestart, "*"}, + {"user", ResourceServers, ActionCommand, "*"}, + {"user", ResourceServerConfig, ActionRead, "*"}, + {"user", ResourceMods, ActionRead, "*"}, + {"user", ResourceModpacks, ActionRead, "*"}, + {"user", ResourceModules, ActionRead, "*"}, + {"user", ResourceModuleTemplates, ActionRead, "*"}, + {"user", ResourceFiles, ActionRead, "*"}, + {"user", ResourceTasks, ActionRead, "*"}, + {"user", ResourceProxy, ActionRead, "*"}, + {"anonymous", ResourceServers, ActionRead, "*"}, + {"anonymous", ResourceServerConfig, ActionRead, "*"}, + {"anonymous", ResourceMods, ActionRead, "*"}, + {"anonymous", ResourceModpacks, ActionRead, "*"}, + {"anonymous", ResourceModules, ActionRead, "*"}, + {"anonymous", ResourceModuleTemplates, ActionRead, "*"}, + {"anonymous", ResourceFiles, ActionRead, "*"}, + {"anonymous", ResourceTasks, ActionRead, "*"}, + {"anonymous", ResourceProxy, ActionRead, "*"}, + } + for _, p := range policies { + if p[0] == "anonymous" && !anonymousEnabled { + continue + } + has, err := e.enforcer.HasPolicy(p[0], p[1], p[2], p[3]) + if err != nil { + return err + } + if !has { + if _, err = e.enforcer.AddPolicy(p[0], p[1], p[2], p[3]); err != nil { + return err + } + } + } + + if !anonymousEnabled { + e.enforcer.RemoveFilteredPolicy(0, "anonymous") + } + + return e.enforcer.SavePolicy() +} + +// Enforce checks if any of the given roles allows the specified action on a +// resource with the given object ID. Returns true on the first matching role. +func (e *Enforcer) Enforce(roles []string, resource, action, objectID string) (bool, error) { + for _, role := range roles { + allowed, err := e.enforcer.Enforce(role, resource, action, objectID) + if err != nil { + return false, err + } + if allowed { + return true, nil + } + } + return false, nil +} + +// GetPermissionsForRole returns all permissions currently assigned to the role. +func (e *Enforcer) GetPermissionsForRole(role string) []Permission { + policies, err := e.enforcer.GetFilteredPolicy(0, role) + if err != nil { + return nil + } + perms := make([]Permission, 0, len(policies)) + for _, p := range policies { + if len(p) >= 4 { + perms = append(perms, Permission{ + Resource: p[1], + Action: p[2], + ObjectID: p[3], + }) + } + } + return perms +} + +// SetPermissionsForRole replaces all permissions for a role atomically. +// The admin role cannot be modified. +func (e *Enforcer) SetPermissionsForRole(role string, perms []Permission) error { + // Don't modify admin role + if strings.ToLower(role) == "admin" { + return fmt.Errorf("cannot modify admin role permissions") + } + + // Remove existing permissions + e.enforcer.RemoveFilteredPolicy(0, role) + + // Add new permissions + for _, p := range perms { + objectID := p.ObjectID + if objectID == "" { + objectID = "*" + } + _, err := e.enforcer.AddPolicy(role, p.Resource, p.Action, objectID) + if err != nil { + return err + } + } + + return e.enforcer.SavePolicy() +} + +// GetPermissionMatrix returns a map of role names to their permission slices, +// covering all roles that have any policy defined. +func (e *Enforcer) GetPermissionMatrix() map[string][]Permission { + policies, err := e.enforcer.GetPolicy() + if err != nil { + return nil + } + matrix := make(map[string][]Permission) + for _, p := range policies { + if len(p) >= 4 { + role := p[0] + matrix[role] = append(matrix[role], Permission{ + Resource: p[1], + Action: p[2], + ObjectID: p[3], + }) + } + } + return matrix +} diff --git a/internal/rbac/resources.go b/internal/rbac/resources.go new file mode 100644 index 0000000..b2bc950 --- /dev/null +++ b/internal/rbac/resources.go @@ -0,0 +1,97 @@ +package rbac + +// Resource constants +const ( + ResourceServers = "servers" + ResourceServerConfig = "server_config" + ResourceMods = "mods" + ResourceModpacks = "modpacks" + ResourceModules = "modules" + ResourceModuleTemplates = "module_templates" + ResourceFiles = "files" + ResourceTasks = "tasks" + ResourceProxy = "proxy" + ResourceUsers = "users" + ResourceRoles = "roles" + ResourceSettings = "settings" + ResourceSupport = "support" + ResourceUploads = "uploads" +) + +// Action constants +const ( + ActionRead = "read" + ActionCreate = "create" + ActionUpdate = "update" + ActionDelete = "delete" + ActionStart = "start" + ActionStop = "stop" + ActionRestart = "restart" + ActionCommand = "command" +) + +// ResourceActionEntry pairs a resource with its valid actions. +type ResourceActionEntry struct { + Resource string + Actions []string +} + +// AllActions in display order. +var AllActions = []string{ + ActionRead, ActionCreate, ActionUpdate, ActionDelete, + ActionStart, ActionStop, ActionRestart, ActionCommand, +} + +// AllResources in display order. +var AllResources = []string{ + ResourceServers, ResourceServerConfig, ResourceMods, + ResourceModpacks, ResourceModules, ResourceModuleTemplates, + ResourceFiles, ResourceTasks, ResourceProxy, + ResourceUsers, ResourceRoles, ResourceSettings, + ResourceSupport, ResourceUploads, +} + +// ResourceScopeSource maps each scopeable resource to the resource that +// provides its scope objects. For example, files are scoped by server_id, +// so ResourceFiles → ResourceServers. Resources absent from this map +// (users, roles, settings, support, uploads) have no per-object scoping. +var ResourceScopeSource = map[string]string{ + ResourceServers: ResourceServers, + ResourceServerConfig: ResourceServers, + ResourceFiles: ResourceServers, + ResourceMods: ResourceServers, + ResourceModules: ResourceModules, + ResourceModuleTemplates: ResourceModuleTemplates, + ResourceModpacks: ResourceModpacks, + ResourceProxy: ResourceProxy, + ResourceTasks: ResourceTasks, +} + +// ResourceActionsFromProcedures derives which actions are valid for each +// resource by inspecting the ProcedurePermissions mapping. Maintains stable +// resource ordering via AllResources and stable action ordering via AllActions. +func ResourceActionsFromProcedures() []ResourceActionEntry { + actionSet := make(map[string]map[string]bool) + for _, pp := range ProcedurePermissions { + if actionSet[pp.Resource] == nil { + actionSet[pp.Resource] = make(map[string]bool) + } + actionSet[pp.Resource][pp.Action] = true + } + + entries := make([]ResourceActionEntry, 0, len(AllResources)) + for _, res := range AllResources { + acts, ok := actionSet[res] + if !ok { + continue + } + var ordered []string + for _, a := range AllActions { + if acts[a] { + ordered = append(ordered, a) + } + } + entries = append(entries, ResourceActionEntry{Resource: res, Actions: ordered}) + } + return entries +} diff --git a/internal/rpc/server.go b/internal/rpc/server.go index 37d8860..203bb33 100644 --- a/internal/rpc/server.go +++ b/internal/rpc/server.go @@ -2,6 +2,7 @@ package rpc import ( "context" + "fmt" "net/http" "slices" "strings" @@ -16,6 +17,7 @@ import ( "github.com/nickheyer/discopanel/internal/metrics" "github.com/nickheyer/discopanel/internal/module" "github.com/nickheyer/discopanel/internal/proxy" + "github.com/nickheyer/discopanel/internal/rbac" "github.com/nickheyer/discopanel/internal/rpc/services" "github.com/nickheyer/discopanel/internal/scheduler" "github.com/nickheyer/discopanel/internal/ws" @@ -25,6 +27,8 @@ import ( web "github.com/nickheyer/discopanel/web/discopanel" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" ) // Server represents the Connect RPC server @@ -36,7 +40,8 @@ type Server struct { handler http.Handler proxyManager *proxy.Manager authManager *auth.Manager - authMiddleware *auth.Middleware + enforcer *rbac.Enforcer + oidcHandler *auth.OIDCHandler logStreamer *logger.LogStreamer scheduler *scheduler.Scheduler metricsCollector *metrics.Collector @@ -47,13 +52,28 @@ type Server struct { // Creates new Connect RPC server func NewServer(store *storage.Store, docker *docker.Client, cfg *config.Config, proxyManager *proxy.Manager, sched *scheduler.Scheduler, metricsCollector *metrics.Collector, moduleManager *module.Manager, log *logger.Logger) *Server { + // Initialize RBAC enforcer + enforcer, err := rbac.NewEnforcer(store.DB()) + if err != nil { + log.Error("Failed to initialize RBAC enforcer: %v", err) + } + if enforcer != nil { + if err := enforcer.SeedDefaultPolicies(cfg.Auth.AnonymousAccess); err != nil { + log.Error("Failed to seed default policies: %v", err) + } + } + // Initialize auth manager - authManager := auth.NewManager(store) - authMiddleware := auth.NewMiddleware(authManager, store) + authManager, err := auth.NewManager(store, enforcer, &cfg.Auth) + if err != nil { + log.Error("Failed to initialize auth manager: %v", err) + } - // Initialize auth on startup - if err := authManager.InitializeAuth(context.Background()); err != nil { - log.Error("Failed to initialize authentication: %v", err) + // Initialize OIDC handler + oidcHandler, err := auth.NewOIDCHandler(authManager, store, &cfg.Auth.OIDC, log) + if err != nil { + log.Warn("Failed to initialize OIDC handler: %v", err) + oidcHandler, _ = auth.NewOIDCHandler(authManager, store, &config.OIDCConfig{}, log) } // Initialize log streamer @@ -65,7 +85,7 @@ func NewServer(store *storage.Store, docker *docker.Client, cfg *config.Config, uploadManager := upload.NewManager(cfg.Storage.TempDir, uploadTTL, cfg.Upload.MaxUploadSize, log) // Initialize WebSocket hub - wsHub := ws.NewHub(logStreamer, authManager, store, docker, log) + wsHub := ws.NewHub(logStreamer, authManager, enforcer, store, docker, log) go wsHub.Run() s := &Server{ @@ -75,7 +95,8 @@ func NewServer(store *storage.Store, docker *docker.Client, cfg *config.Config, log: log, proxyManager: proxyManager, authManager: authManager, - authMiddleware: authMiddleware, + enforcer: enforcer, + oidcHandler: oidcHandler, logStreamer: logStreamer, scheduler: sched, metricsCollector: metricsCollector, @@ -119,6 +140,7 @@ func (s *Server) setupHandler() { discopanelv1connect.ModpackServiceName, discopanelv1connect.ModuleServiceName, discopanelv1connect.ProxyServiceName, + discopanelv1connect.RoleServiceName, discopanelv1connect.ServerServiceName, discopanelv1connect.SupportServiceName, discopanelv1connect.TaskServiceName, @@ -131,6 +153,12 @@ func (s *Server) setupHandler() { // Register WebSocket handler mux.Handle("/ws", s.wsHub) + // Register OIDC HTTP handlers + if s.oidcHandler != nil && s.oidcHandler.IsEnabled() { + mux.HandleFunc("/api/v1/auth/oidc/login", s.oidcHandler.HandleLogin) + mux.HandleFunc("/api/v1/auth/oidc/callback", s.oidcHandler.HandleCallback) + } + // Serve frontend for non-RPC routes s.setupFrontend(mux) @@ -141,7 +169,7 @@ func (s *Server) setupHandler() { // Registers all Connect RPC service handlers func (s *Server) registerServices(mux *http.ServeMux, opts []connect.HandlerOption) { // Create service instances - authService := services.NewAuthService(s.store, s.authManager, s.log) + authService := services.NewAuthService(s.store, s.authManager, s.enforcer, s.oidcHandler, s.log) configService := services.NewConfigService(s.store, s.config, s.docker, s.log) fileService := services.NewFileService(s.store, s.docker, s.uploadManager, s.log) minecraftService := services.NewMinecraftService(s.store, s.docker, s.log) @@ -152,6 +180,7 @@ func (s *Server) registerServices(mux *http.ServeMux, opts []connect.HandlerOpti supportService := services.NewSupportService(s.store, s.docker, s.config, s.log) taskService := services.NewTaskService(s.store, s.scheduler, s.log) userService := services.NewUserService(s.store, s.authManager, s.log) + roleService := services.NewRoleService(s.store, s.enforcer, s.log) moduleService := services.NewModuleService(s.store, s.docker, s.moduleManager, s.proxyManager, s.config, s.logStreamer, s.log) uploadService := services.NewUploadService(s.uploadManager, s.config, s.log) @@ -189,6 +218,9 @@ func (s *Server) registerServices(mux *http.ServeMux, opts []connect.HandlerOpti userPath, userHandler := discopanelv1connect.NewUserServiceHandler(userService, opts...) mux.Handle(userPath, userHandler) + rolePath, roleHandler := discopanelv1connect.NewRoleServiceHandler(roleService, opts...) + mux.Handle(rolePath, roleHandler) + modulePath, moduleHandler := discopanelv1connect.NewModuleServiceHandler(moduleService, opts...) mux.Handle(modulePath, moduleHandler) @@ -214,23 +246,77 @@ func (s *Server) loggingInterceptor() connect.UnaryInterceptorFunc { } } -// Creates a Connect interceptor for authentication +// Creates a Connect interceptor for authentication and authorization func (s *Server) authInterceptor() connect.UnaryInterceptorFunc { return func(next connect.UnaryFunc) connect.UnaryFunc { return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { - // Extract token from auth header + procedure := req.Spec().Procedure + + // Public procedures - no auth required + if rbac.PublicProcedures[procedure] { + return next(ctx, req) + } + + // If no auth providers are enabled, bypass auth entirely - grant full admin access + if !s.authManager.IsAnyAuthEnabled() { + superUser := &auth.AuthenticatedUser{ + ID: "admin", + Username: "admin", + Roles: []string{"admin"}, + Provider: "none", + } + ctx = auth.WithUser(ctx, superUser) + return next(ctx, req) + } + + // Extract token from Authorization header token := "" if authHeader := req.Header().Get("Authorization"); authHeader != "" { token, _ = strings.CutPrefix(authHeader, "Bearer ") token, _ = strings.CutPrefix(token, "bearer ") } - // Validate user session or return valid anon user/session if auth disabled - user, err := s.authManager.ValidateSession(ctx, token) - if err == nil && user != nil { - ctx = context.WithValue(ctx, auth.UserContextKey, user) - } else if err != nil { - s.log.Debug("Auth: Token validation failed for %s: %v", req.Spec().Procedure, err) + var user *auth.AuthenticatedUser + + if token != "" { + // Validate session + var err error + user, err = s.authManager.ValidateSession(ctx, token) + if err != nil { + s.log.Debug("Auth: Token validation failed for %s: %v", procedure, err) + return nil, connect.NewError(connect.CodeUnauthenticated, err) + } + } else if s.authManager.IsAnonymousAccessEnabled() { + // Anonymous access + user = s.authManager.AnonymousUser() + } else { + return nil, connect.NewError(connect.CodeUnauthenticated, auth.ErrInvalidToken) + } + + // Set user in context + ctx = auth.WithUser(ctx, user) + + // Authenticated-only procedures (no specific resource permission needed) + if rbac.AuthenticatedOnlyProcedures[procedure] { + return next(ctx, req) + } + + // Check resource permission + if perm, ok := rbac.ProcedurePermissions[procedure]; ok { + if s.enforcer != nil { + objectID := "*" + if perm.ObjectIDField != "" { + objectID = extractObjectID(req, perm.ObjectIDField) + } + allowed, err := s.enforcer.Enforce(user.Roles, perm.Resource, perm.Action, objectID) + if err != nil { + s.log.Error("RBAC enforcement error: %v", err) + return nil, connect.NewError(connect.CodeInternal, err) + } + if !allowed { + return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("insufficient permissions for %s/%s", perm.Resource, perm.Action)) + } + } } return next(ctx, req) @@ -238,19 +324,20 @@ func (s *Server) authInterceptor() connect.UnaryInterceptorFunc { } } +// pollingProcedures lists endpoints that are called frequently and should be excluded from logging. +var pollingProcedures = []string{ + "/discopanel.v1.AuthService/GetAuthStatus", + "/discopanel.v1.ServerService/ListServers", + "/discopanel.v1.ServerService/GetServer", + "/discopanel.v1.ServerService/GetServerLogs", + "/discopanel.v1.ProxyService/GetProxyStatus", + "/discopanel.v1.SupportService/GetApplicationLogs", + "/discopanel.v1.UploadService/UploadChunk", + "/discopanel.v1.UploadService/GetUploadStatus", +} + // Checks if a procedure is a polling endpoint or high-frequency endpoint func (s *Server) isPollingProcedure(procedure string) bool { - pollingProcedures := []string{ - "/discopanel.v1.AuthService/GetAuthStatus", - "/discopanel.v1.ServerService/ListServers", - "/discopanel.v1.ServerService/GetServer", - "/discopanel.v1.ServerService/GetServerLogs", - "/discopanel.v1.ProxyService/GetProxyStatus", - "/discopanel.v1.SupportService/GetApplicationLogs", - "/discopanel.v1.UploadService/UploadChunk", - "/discopanel.v1.UploadService/GetUploadStatus", - } - return slices.Contains(pollingProcedures, procedure) } @@ -286,7 +373,7 @@ func (s *Server) createFrontendHandler(fs http.FileSystem) http.HandlerFunc { return } - // Try to serve the file + // Try to serve the file directly (static assets like JS, CSS, images) path := r.URL.Path if path == "/" { path = "/index.html" @@ -331,6 +418,24 @@ func isConnectPath(path string) bool { return false } +// extractObjectID extracts a named string field from a protobuf request message +// using reflection. Falls back to "*" if the field is missing or empty. +func extractObjectID(req connect.AnyRequest, fieldName string) string { + msg, ok := req.Any().(proto.Message) + if !ok { + return "*" + } + fd := msg.ProtoReflect().Descriptor().Fields().ByName(protoreflect.Name(fieldName)) + if fd == nil { + return "*" + } + val := msg.ProtoReflect().Get(fd) + if str := val.String(); str != "" { + return str + } + return "*" +} + // Starts log streaming for a container func (s *Server) StartLogStreaming(containerID string) error { return s.logStreamer.StartStreaming(containerID) diff --git a/internal/rpc/services/auth.go b/internal/rpc/services/auth.go index a64f9d4..506253a 100644 --- a/internal/rpc/services/auth.go +++ b/internal/rpc/services/auth.go @@ -3,420 +3,292 @@ package services import ( "context" "errors" - "net/http" - "time" + "strings" "connectrpc.com/connect" "github.com/nickheyer/discopanel/internal/auth" storage "github.com/nickheyer/discopanel/internal/db" + "github.com/nickheyer/discopanel/internal/rbac" "github.com/nickheyer/discopanel/pkg/logger" v1 "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1" "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1/discopanelv1connect" "google.golang.org/protobuf/types/known/timestamppb" ) -// Compile-time check that AuthService implements the interface var _ discopanelv1connect.AuthServiceHandler = (*AuthService)(nil) -// AuthService implements the Auth service type AuthService struct { store *storage.Store authManager *auth.Manager + enforcer *rbac.Enforcer + oidcHandler *auth.OIDCHandler log *logger.Logger } -// NewAuthService creates a new auth service -func NewAuthService(store *storage.Store, authManager *auth.Manager, log *logger.Logger) *AuthService { +func NewAuthService(store *storage.Store, authManager *auth.Manager, enforcer *rbac.Enforcer, oidcHandler *auth.OIDCHandler, log *logger.Logger) *AuthService { return &AuthService{ store: store, authManager: authManager, + enforcer: enforcer, + oidcHandler: oidcHandler, log: log, } } -// Helper functions for auth service - -// dbUserToProto converts a database User to a proto User -func dbUserToProto(user *storage.User) *v1.User { - if user == nil { - return nil - } - - protoUser := &v1.User{ - Id: user.ID, - Username: user.Username, - IsActive: user.IsActive, - CreatedAt: timestamppb.New(user.CreatedAt), - UpdatedAt: timestamppb.New(user.UpdatedAt), - } - - // Map role - switch user.Role { - case storage.RoleAdmin: - protoUser.Role = v1.UserRole_USER_ROLE_ADMIN - case storage.RoleEditor: - protoUser.Role = v1.UserRole_USER_ROLE_EDITOR - case storage.RoleViewer: - protoUser.Role = v1.UserRole_USER_ROLE_VIEWER - default: - protoUser.Role = v1.UserRole_USER_ROLE_UNSPECIFIED - } - - // Handle optional email - if user.Email != nil && *user.Email != "" { - protoUser.Email = user.Email - } - - // Note: recovery_key is intentionally not included in the proto response for security reasons - // It should only be shown to the user when first created - - return protoUser -} - -// protoRoleToDBRole converts a proto UserRole to a DB UserRole -func protoRoleToDBRole(role v1.UserRole) storage.UserRole { - switch role { - case v1.UserRole_USER_ROLE_ADMIN: - return storage.RoleAdmin - case v1.UserRole_USER_ROLE_EDITOR: - return storage.RoleEditor - case v1.UserRole_USER_ROLE_VIEWER: - return storage.RoleViewer - default: - return storage.RoleViewer - } -} - -// extractTokenFromHeaders extracts the auth token from the request headers -func extractTokenFromHeaders(headers http.Header) string { - // Try Authorization header first - authHeader := headers.Get("Authorization") - if authHeader != "" && len(authHeader) > 7 && authHeader[:7] == "Bearer " { - return authHeader[7:] - } - - // Try cookie - cookies := headers.Values("Cookie") - for _, cookie := range cookies { - if len(cookie) > 11 && cookie[:11] == "auth_token=" { - // Simple cookie parsing - find the value up to semicolon or end - value := cookie[11:] - if idx := indexByte(value, ';'); idx >= 0 { - value = value[:idx] - } - return value - } - } - - return "" -} - -// indexByte returns the index of the first instance of c in s, or -1 if not present -func indexByte(s string, c byte) int { - for i := 0; i < len(s); i++ { - if s[i] == c { - return i - } - } - return -1 -} - -// GetAuthStatus checks if auth is enabled func (s *AuthService) GetAuthStatus(ctx context.Context, req *connect.Request[v1.GetAuthStatusRequest]) (*connect.Response[v1.GetAuthStatusResponse], error) { - authConfig, _, err := s.store.GetAuthConfig(ctx) - if err != nil { - s.log.Error("Failed to get auth config: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get auth config")) - } - - // Check if this is first user setup userCount, err := s.store.CountUsers(ctx) if err != nil { s.log.Error("Failed to count users: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to check user count")) + userCount = 0 } + oidcEnabled := s.oidcHandler != nil && s.oidcHandler.IsEnabled() + return connect.NewResponse(&v1.GetAuthStatusResponse{ - Enabled: authConfig.Enabled, - FirstUserSetup: userCount == 0, - AllowRegistration: authConfig.AllowRegistration, + LocalAuthEnabled: s.authManager.IsLocalAuthEnabled(), + OidcEnabled: oidcEnabled, + AllowRegistration: s.authManager.IsRegistrationAllowed(), + FirstUserSetup: userCount == 0, + AnonymousAccessEnabled: s.authManager.IsAnonymousAccessEnabled(), }), nil } -// Login authenticates user credentials func (s *AuthService) Login(ctx context.Context, req *connect.Request[v1.LoginRequest]) (*connect.Response[v1.LoginResponse], error) { msg := req.Msg - // Validate input if msg.Username == "" || msg.Password == "" { return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("username and password are required")) } - // Attempt login - user, token, err := s.authManager.Login(ctx, msg.Username, msg.Password) + user, roles, token, expiresAt, err := s.authManager.Login(ctx, msg.Username, msg.Password) if err != nil { - switch err { - case auth.ErrInvalidCredentials: + if errors.Is(err, auth.ErrInvalidCredentials) || errors.Is(err, auth.ErrUserNotActive) { return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid credentials")) - case auth.ErrUserNotActive: - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("user account is not active")) - case auth.ErrAuthDisabled: - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("authentication is disabled")) - default: - s.log.Error("Login error: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("login failed")) } + if errors.Is(err, auth.ErrLocalAuthDisabled) { + return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("local authentication is disabled")) + } + s.log.Error("Login failed: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("login failed")) } - // Get auth config for session timeout - authConfig, _, _ := s.store.GetAuthConfig(ctx) - expiresAt := time.Now().Add(time.Duration(authConfig.SessionTimeout) * time.Second) - - // Set cookie in response headers - resp := connect.NewResponse(&v1.LoginResponse{ + return connect.NewResponse(&v1.LoginResponse{ Token: token, - User: dbUserToProto(user), + User: dbUserToProto(user, roles), ExpiresAt: timestamppb.New(expiresAt), - }) - - // Set auth cookie - cookie := &http.Cookie{ - Name: "auth_token", - Value: token, - Path: "/", - Expires: expiresAt, - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - } - resp.Header().Set("Set-Cookie", cookie.String()) - - return resp, nil + }), nil } -// Logout invalidates session token func (s *AuthService) Logout(ctx context.Context, req *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) { - // Extract token from headers - token := extractTokenFromHeaders(req.Header()) + // Extract token from Authorization header + token := "" + if authHeader := req.Header().Get("Authorization"); authHeader != "" { + token, _ = strings.CutPrefix(authHeader, "Bearer ") + token, _ = strings.CutPrefix(token, "bearer ") + } if token != "" { - // Delete session if err := s.authManager.Logout(ctx, token); err != nil { - s.log.Error("Logout error: %v", err) - // Don't fail the logout request even if session deletion fails + s.log.Debug("Logout error: %v", err) } } - // Clear cookie in response - resp := connect.NewResponse(&v1.LogoutResponse{ - Message: "Logged out successfully", - }) - - // Clear auth cookie - cookie := &http.Cookie{ - Name: "auth_token", - Value: "", - Path: "/", - Expires: time.Now().Add(-time.Hour), - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - } - resp.Header().Set("Set-Cookie", cookie.String()) - - return resp, nil + return connect.NewResponse(&v1.LogoutResponse{ + Message: "logged out", + }), nil } -// Register creates a new user account func (s *AuthService) Register(ctx context.Context, req *connect.Request[v1.RegisterRequest]) (*connect.Response[v1.RegisterResponse], error) { msg := req.Msg - // Validate input + // Check if registration is allowed + userCount, _ := s.store.CountUsers(ctx) + isFirstUser := userCount == 0 + + if !isFirstUser && !s.authManager.IsRegistrationAllowed() { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("registration is disabled")) + } + if msg.Username == "" || msg.Password == "" { return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("username and password are required")) } - // Check if this is first user setup - userCount, err := s.store.CountUsers(ctx) + user, err := s.authManager.CreateLocalUser(ctx, msg.Username, msg.Email, msg.Password) if err != nil { - s.log.Error("Failed to check user count: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to check user count")) + s.log.Error("Registration failed: %v", err) + return nil, connect.NewError(connect.CodeAlreadyExists, errors.New("registration failed")) } - // Determine role - var role storage.UserRole - if userCount == 0 { - // First user is always admin - role = storage.RoleAdmin + // First user gets admin role, others get default roles + if isFirstUser { + _ = s.store.AssignRole(ctx, user.ID, "admin", "local") } else { - // Check if registration is allowed - authConfig, _, err := s.store.GetAuthConfig(ctx) - if err != nil { - s.log.Error("Failed to get auth config: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get auth config")) + defaultRoles, _ := s.store.GetDefaultRoles(ctx) + for _, role := range defaultRoles { + _ = s.store.AssignRole(ctx, user.ID, role.Name, "local") } - - if !authConfig.AllowRegistration { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("registration is disabled")) - } - - // New users default to viewer role - role = storage.RoleViewer - } - - // Create user - user, err := s.authManager.CreateUser(ctx, msg.Username, msg.Email, msg.Password, role) - if err != nil { - s.log.Error("Failed to create user: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to create user")) } - // If this was the first user, enable authentication - if userCount == 0 { - authConfig, _, _ := s.store.GetAuthConfig(ctx) - authConfig.Enabled = true - if err := s.store.SaveAuthConfig(ctx, authConfig); err != nil { - s.log.Error("Failed to enable authentication: %v", err) - } - } + roles, _ := s.store.GetUserRoleNames(ctx, user.ID) return connect.NewResponse(&v1.RegisterResponse{ - User: dbUserToProto(user), + User: dbUserToProto(user, roles), }), nil } -// ResetPassword resets password with recovery key -func (s *AuthService) ResetPassword(ctx context.Context, req *connect.Request[v1.ResetPasswordRequest]) (*connect.Response[v1.ResetPasswordResponse], error) { - msg := req.Msg +func (s *AuthService) GetCurrentUser(ctx context.Context, req *connect.Request[v1.GetCurrentUserRequest]) (*connect.Response[v1.GetCurrentUserResponse], error) { + authUser := auth.GetUserFromContext(ctx) + if authUser == nil { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("not authenticated")) + } - if err := s.authManager.ResetPassword(ctx, msg.Username, msg.RecoveryKey, msg.NewPassword); err != nil { - if err == auth.ErrInvalidCredentials { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid recovery key or username")) + // When all auth is disabled, the interceptor injects a synthetic admin + // that doesn't exist in DB. Return it directly. + if !s.authManager.IsAnyAuthEnabled() { + roles := authUser.Roles + protoUser := &v1.User{ + Id: authUser.ID, + Username: authUser.Username, + AuthProvider: authUser.Provider, + IsActive: true, + Roles: roles, } - s.log.Error("Failed to reset password: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to reset password")) - } - return connect.NewResponse(&v1.ResetPasswordResponse{ - Message: "Password reset successfully", - }), nil -} + var permissions []*v1.Permission + if s.enforcer != nil { + for _, role := range roles { + for _, p := range s.enforcer.GetPermissionsForRole(role) { + permissions = append(permissions, &v1.Permission{ + Resource: p.Resource, + Action: p.Action, + ObjectId: p.ObjectID, + }) + } + } + } -// GetAuthConfig gets auth configuration -func (s *AuthService) GetAuthConfig(ctx context.Context, req *connect.Request[v1.GetAuthConfigRequest]) (*connect.Response[v1.GetAuthConfigResponse], error) { - config, _, err := s.store.GetAuthConfig(ctx) - if err != nil { - s.log.Error("Failed to get auth config: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get auth config")) + return connect.NewResponse(&v1.GetCurrentUserResponse{ + User: protoUser, + Permissions: permissions, + }), nil } - // If auth is enabled, check for admin permission - if config.Enabled { - user := auth.GetUserFromContext(ctx) - if user == nil || user.Role != storage.RoleAdmin { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("admin access required")) + // Fetch user from db + dbUser, err := s.store.GetUser(ctx, authUser.ID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get user")) + } + + // Fetch roles from db + roles, _ := s.store.GetUserRoleNames(ctx, authUser.ID) + + // Collect permissions from all user roles via the RBAC enforcer + var permissions []*v1.Permission + if s.enforcer != nil { + seen := make(map[string]bool) + for _, role := range roles { + for _, p := range s.enforcer.GetPermissionsForRole(role) { + key := p.Resource + ":" + p.Action + ":" + p.ObjectID + if !seen[key] { + seen[key] = true + permissions = append(permissions, &v1.Permission{ + Resource: p.Resource, + Action: p.Action, + ObjectId: p.ObjectID, + }) + } + } } } - return connect.NewResponse(&v1.GetAuthConfigResponse{ - Enabled: config.Enabled, - SessionTimeout: int32(config.SessionTimeout), - RequireEmailVerify: config.RequireEmailVerify, - AllowRegistration: config.AllowRegistration, + return connect.NewResponse(&v1.GetCurrentUserResponse{ + User: dbUserToProto(dbUser, roles), + Permissions: permissions, }), nil } -// UpdateAuthConfig modifies auth configuration -func (s *AuthService) UpdateAuthConfig(ctx context.Context, req *connect.Request[v1.UpdateAuthConfigRequest]) (*connect.Response[v1.UpdateAuthConfigResponse], error) { - msg := req.Msg - - config, _, err := s.store.GetAuthConfig(ctx) - if err != nil { - s.log.Error("Failed to get auth config: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get auth config")) +func (s *AuthService) ChangePassword(ctx context.Context, req *connect.Request[v1.ChangePasswordRequest]) (*connect.Response[v1.ChangePasswordResponse], error) { + user := auth.GetUserFromContext(ctx) + if user == nil { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("not authenticated")) } - // If auth is currently enabled, require admin permission - if config.Enabled { - user := auth.GetUserFromContext(ctx) - if user == nil || user.Role != storage.RoleAdmin { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("admin access required")) - } + msg := req.Msg + if msg.OldPassword == "" || msg.NewPassword == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("old and new passwords are required")) } - // Check if trying to enable auth for the first time - requiresFirstUser := false - if msg.Enabled != nil && *msg.Enabled && !config.Enabled { - // Check if any users exist - userCount, err := s.store.CountUsers(ctx) - if err != nil { - s.log.Error("Failed to check user count: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to check user count")) - } - - if userCount == 0 { - // Need to create first admin user - requiresFirstUser = true - return connect.NewResponse(&v1.UpdateAuthConfigResponse{ - Message: "Create an admin account to enable authentication", - RequiresFirstUser: requiresFirstUser, - }), nil + if err := s.authManager.ChangePassword(ctx, user.ID, msg.OldPassword, msg.NewPassword); err != nil { + if errors.Is(err, auth.ErrInvalidCredentials) { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("incorrect current password")) } + s.log.Error("Change password failed: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to change password")) } - // Update allowed fields - if msg.Enabled != nil { - config.Enabled = *msg.Enabled - } - if msg.SessionTimeout != nil { - config.SessionTimeout = int(*msg.SessionTimeout) - } - if msg.RequireEmailVerify != nil { - config.RequireEmailVerify = *msg.RequireEmailVerify - } - if msg.AllowRegistration != nil { - config.AllowRegistration = *msg.AllowRegistration - } + return connect.NewResponse(&v1.ChangePasswordResponse{ + Message: "password changed", + }), nil +} - if err := s.store.SaveAuthConfig(ctx, config); err != nil { - s.log.Error("Failed to update auth config: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to update auth config")) +func (s *AuthService) GetOIDCLoginURL(ctx context.Context, req *connect.Request[v1.GetOIDCLoginURLRequest]) (*connect.Response[v1.GetOIDCLoginURLResponse], error) { + if s.oidcHandler == nil || !s.oidcHandler.IsEnabled() { + return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("OIDC is not enabled")) } - return connect.NewResponse(&v1.UpdateAuthConfigResponse{ - Message: "Auth config updated successfully", - RequiresFirstUser: false, + return connect.NewResponse(&v1.GetOIDCLoginURLResponse{ + LoginUrl: "/api/v1/auth/oidc/login", }), nil } -// GetCurrentUser gets authenticated user info -func (s *AuthService) GetCurrentUser(ctx context.Context, req *connect.Request[v1.GetCurrentUserRequest]) (*connect.Response[v1.GetCurrentUserResponse], error) { - user := auth.GetUserFromContext(ctx) - if user == nil { - return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("not authenticated")) +func (s *AuthService) GetAuthConfig(ctx context.Context, req *connect.Request[v1.GetAuthConfigRequest]) (*connect.Response[v1.GetAuthConfigResponse], error) { + cfg := s.authManager.GetConfig() + oidcEnabled := s.oidcHandler != nil && s.oidcHandler.IsEnabled() + + userCount, err := s.store.CountUsers(ctx) + if err != nil { + s.log.Error("Failed to count users: %v", err) + userCount = 0 } - return connect.NewResponse(&v1.GetCurrentUserResponse{ - User: dbUserToProto(user), - }), nil + resp := &v1.GetAuthConfigResponse{ + LocalAuthEnabled: cfg.Local.Enabled, + AllowRegistration: cfg.Local.AllowRegistration, + AnonymousAccess: cfg.AnonymousAccess, + SessionTimeout: int32(cfg.SessionTimeout), + OidcEnabled: oidcEnabled, + FirstUserSetup: userCount == 0, + } + + if oidcEnabled { + resp.OidcIssuerUri = &cfg.OIDC.IssuerURI + resp.OidcClientId = &cfg.OIDC.ClientID + resp.OidcRedirectUrl = &cfg.OIDC.RedirectURL + resp.OidcScopes = cfg.OIDC.Scopes + resp.OidcRoleClaim = &cfg.OIDC.RoleClaim + } + + return connect.NewResponse(resp), nil } -// ChangePassword changes user's own password -func (s *AuthService) ChangePassword(ctx context.Context, req *connect.Request[v1.ChangePasswordRequest]) (*connect.Response[v1.ChangePasswordResponse], error) { +func (s *AuthService) UpdateAuthSettings(ctx context.Context, req *connect.Request[v1.UpdateAuthSettingsRequest]) (*connect.Response[v1.UpdateAuthSettingsResponse], error) { msg := req.Msg - user := auth.GetUserFromContext(ctx) - if user == nil { - return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("not authenticated")) + if err := s.authManager.UpdateSettings(ctx, msg.LocalAuthEnabled, msg.AllowRegistration, msg.AnonymousAccess, msg.SessionTimeout); err != nil { + if errors.Is(err, auth.ErrSessionTimeoutMin) { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + s.log.Error("Failed to update auth settings: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to update auth settings")) } - if err := s.authManager.ChangePassword(ctx, user.ID, msg.OldPassword, msg.NewPassword); err != nil { - if err == auth.ErrInvalidCredentials { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid old password")) - } - s.log.Error("Failed to change password: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to change password")) + // Return the updated config + configResp, err := s.GetAuthConfig(ctx, connect.NewRequest(&v1.GetAuthConfigRequest{})) + if err != nil { + return nil, err } - return connect.NewResponse(&v1.ChangePasswordResponse{ - Message: "Password changed successfully", + return connect.NewResponse(&v1.UpdateAuthSettingsResponse{ + Config: configResp.Msg, }), nil } diff --git a/internal/rpc/services/config.go b/internal/rpc/services/config.go index 7fc0191..cbef6a4 100644 --- a/internal/rpc/services/config.go +++ b/internal/rpc/services/config.go @@ -9,7 +9,6 @@ import ( "time" "connectrpc.com/connect" - "github.com/nickheyer/discopanel/internal/auth" "github.com/nickheyer/discopanel/internal/config" storage "github.com/nickheyer/discopanel/internal/db" "github.com/nickheyer/discopanel/internal/docker" @@ -127,10 +126,6 @@ func (s *ConfigService) UpdateServerConfig(ctx context.Context, req *connect.Req // Gets global settings func (s *ConfigService) GetGlobalSettings(ctx context.Context, req *connect.Request[v1.GetGlobalSettingsRequest]) (*connect.Response[v1.GetGlobalSettingsResponse], error) { - if err := s.checkAdminAuth(ctx); err != nil { - return nil, err - } - config, _, err := s.store.GetGlobalSettings(ctx) if err != nil { s.log.Error("Failed to get global settings: %v", err) @@ -150,10 +145,6 @@ func (s *ConfigService) GetGlobalSettings(ctx context.Context, req *connect.Requ // Updates global settings func (s *ConfigService) UpdateGlobalSettings(ctx context.Context, req *connect.Request[v1.UpdateGlobalSettingsRequest]) (*connect.Response[v1.UpdateGlobalSettingsResponse], error) { - if err := s.checkAdminAuth(ctx); err != nil { - return nil, err - } - msg := req.Msg config, _, err := s.store.GetGlobalSettings(ctx) if err != nil { @@ -182,24 +173,6 @@ func (s *ConfigService) UpdateGlobalSettings(ctx context.Context, req *connect.R }), nil } -func (s *ConfigService) checkAdminAuth(ctx context.Context) error { - authConfig, _, err := s.store.GetAuthConfig(ctx) - if err != nil { - return connect.NewError(connect.CodeInternal, errors.New("failed to get auth configuration")) - } - - if authConfig.Enabled { - user := auth.GetUserFromContext(ctx) - if user == nil { - return connect.NewError(connect.CodeUnauthenticated, errors.New("authentication required")) - } - if !auth.CheckPermission(user, storage.RoleAdmin) { - return connect.NewError(connect.CodePermissionDenied, errors.New("admin access required")) - } - } - return nil -} - func (s *ConfigService) recreateContainer(ctx context.Context, server *storage.Server, config *storage.ServerConfig) error { oldContainerID := server.ContainerID wasRunning := false diff --git a/internal/rpc/services/role.go b/internal/rpc/services/role.go new file mode 100644 index 0000000..5f3e820 --- /dev/null +++ b/internal/rpc/services/role.go @@ -0,0 +1,424 @@ +package services + +import ( + "context" + "errors" + + "connectrpc.com/connect" + "github.com/google/uuid" + storage "github.com/nickheyer/discopanel/internal/db" + "github.com/nickheyer/discopanel/internal/rbac" + "github.com/nickheyer/discopanel/pkg/logger" + v1 "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1" + "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1/discopanelv1connect" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var _ discopanelv1connect.RoleServiceHandler = (*RoleService)(nil) + +type RoleService struct { + store *storage.Store + enforcer *rbac.Enforcer + log *logger.Logger +} + +func NewRoleService(store *storage.Store, enforcer *rbac.Enforcer, log *logger.Logger) *RoleService { + return &RoleService{ + store: store, + enforcer: enforcer, + log: log, + } +} + +func (s *RoleService) ListRoles(ctx context.Context, req *connect.Request[v1.ListRolesRequest]) (*connect.Response[v1.ListRolesResponse], error) { + roles, err := s.store.ListRoles(ctx) + if err != nil { + s.log.Error("Failed to list roles: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to list roles")) + } + + protoRoles := make([]*v1.Role, 0, len(roles)) + for _, role := range roles { + perms := s.enforcer.GetPermissionsForRole(role.Name) + protoRoles = append(protoRoles, dbRoleToProto(role, perms)) + } + + return connect.NewResponse(&v1.ListRolesResponse{ + Roles: protoRoles, + }), nil +} + +func (s *RoleService) GetRole(ctx context.Context, req *connect.Request[v1.GetRoleRequest]) (*connect.Response[v1.GetRoleResponse], error) { + msg := req.Msg + if msg.Id == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role ID is required")) + } + + role, err := s.store.GetRole(ctx, msg.Id) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, errors.New("role not found")) + } + + perms := s.enforcer.GetPermissionsForRole(role.Name) + + return connect.NewResponse(&v1.GetRoleResponse{ + Role: dbRoleToProto(role, perms), + }), nil +} + +func (s *RoleService) CreateRole(ctx context.Context, req *connect.Request[v1.CreateRoleRequest]) (*connect.Response[v1.CreateRoleResponse], error) { + msg := req.Msg + + if msg.Name == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role name is required")) + } + + role := &storage.Role{ + ID: uuid.New().String(), + Name: msg.Name, + Description: msg.Description, + IsSystem: false, + IsDefault: msg.IsDefault, + } + + if err := s.store.CreateRole(ctx, role); err != nil { + s.log.Error("Failed to create role: %v", err) + return nil, connect.NewError(connect.CodeAlreadyExists, errors.New("failed to create role")) + } + + // Set initial permissions if provided + if len(msg.Permissions) > 0 { + perms := protoPermsToRbac(msg.Permissions) + if err := s.enforcer.SetPermissionsForRole(role.Name, perms); err != nil { + s.log.Error("Failed to set permissions for role %s: %v", role.Name, err) + } + } + + perms := s.enforcer.GetPermissionsForRole(role.Name) + + return connect.NewResponse(&v1.CreateRoleResponse{ + Role: dbRoleToProto(role, perms), + }), nil +} + +func (s *RoleService) UpdateRole(ctx context.Context, req *connect.Request[v1.UpdateRoleRequest]) (*connect.Response[v1.UpdateRoleResponse], error) { + msg := req.Msg + + if msg.Id == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role ID is required")) + } + + role, err := s.store.GetRole(ctx, msg.Id) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, errors.New("role not found")) + } + + if role.IsSystem { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("cannot modify system role")) + } + + if msg.Name != nil { + role.Name = *msg.Name + } + if msg.Description != nil { + role.Description = *msg.Description + } + if msg.IsDefault != nil { + role.IsDefault = *msg.IsDefault + } + + if err := s.store.UpdateRole(ctx, role); err != nil { + s.log.Error("Failed to update role: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to update role")) + } + + perms := s.enforcer.GetPermissionsForRole(role.Name) + + return connect.NewResponse(&v1.UpdateRoleResponse{ + Role: dbRoleToProto(role, perms), + }), nil +} + +func (s *RoleService) DeleteRole(ctx context.Context, req *connect.Request[v1.DeleteRoleRequest]) (*connect.Response[v1.DeleteRoleResponse], error) { + msg := req.Msg + + if msg.Id == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role ID is required")) + } + + role, err := s.store.GetRole(ctx, msg.Id) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, errors.New("role not found")) + } + + if role.IsSystem { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("cannot delete system role")) + } + + // Remove all permissions for this role + _ = s.enforcer.SetPermissionsForRole(role.Name, nil) + + if err := s.store.DeleteRole(ctx, msg.Id); err != nil { + s.log.Error("Failed to delete role: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to delete role")) + } + + return connect.NewResponse(&v1.DeleteRoleResponse{ + Message: "role deleted", + }), nil +} + +func (s *RoleService) GetPermissionMatrix(ctx context.Context, req *connect.Request[v1.GetPermissionMatrixRequest]) (*connect.Response[v1.GetPermissionMatrixResponse], error) { + matrix := s.enforcer.GetPermissionMatrix() + + rolePermsMap := make(map[string]*v1.RolePermissions) + for roleName, perms := range matrix { + protoPerms := make([]*v1.Permission, 0, len(perms)) + for _, p := range perms { + protoPerms = append(protoPerms, &v1.Permission{ + Resource: p.Resource, + Action: p.Action, + ObjectId: p.ObjectID, + }) + } + rolePermsMap[roleName] = &v1.RolePermissions{ + Permissions: protoPerms, + } + } + + // Build resource_actions from procedure mappings + raEntries := rbac.ResourceActionsFromProcedures() + protoRA := make([]*v1.ResourceActions, 0, len(raEntries)) + for _, ra := range raEntries { + protoRA = append(protoRA, &v1.ResourceActions{ + Resource: ra.Resource, + Actions: ra.Actions, + }) + } + + resp := &v1.GetPermissionMatrixResponse{ + ResourceActions: protoRA, + RolePermissions: rolePermsMap, + } + + // Populate available objects for scoped permissions when requested. + // Driven entirely by ProcedurePermissions: any resource with a non-empty + // ObjectIDField is scopeable, and the field name determines which entity + // type provides the objects (e.g. "server_id" → servers). + if req.Msg.IncludeObjects { + type idName struct{ id, name string } + + // Store fetchers keyed by resource constant. + fetchers := map[string]func() []idName{ + rbac.ResourceServers: func() []idName { + items, err := s.store.ListServers(ctx) + if err != nil { + return nil + } + out := make([]idName, len(items)) + for i, x := range items { + out[i] = idName{x.ID, x.Name} + } + return out + }, + rbac.ResourceModules: func() []idName { + items, err := s.store.ListModules(ctx) + if err != nil { + return nil + } + out := make([]idName, len(items)) + for i, x := range items { + out[i] = idName{x.ID, x.Name} + } + return out + }, + rbac.ResourceModuleTemplates: func() []idName { + items, err := s.store.ListModuleTemplates(ctx) + if err != nil { + return nil + } + out := make([]idName, len(items)) + for i, x := range items { + out[i] = idName{x.ID, x.Name} + } + return out + }, + rbac.ResourceProxy: func() []idName { + items, err := s.store.GetProxyListeners(ctx) + if err != nil { + return nil + } + out := make([]idName, len(items)) + for i, x := range items { + out[i] = idName{x.ID, x.Name} + } + return out + }, + rbac.ResourceTasks: func() []idName { + items, err := s.store.ListAllScheduledTasks(ctx) + if err != nil { + return nil + } + out := make([]idName, len(items)) + for i, x := range items { + out[i] = idName{x.ID, x.Name} + } + return out + }, + rbac.ResourceModpacks: func() []idName { + items, _, err := s.store.ListIndexedModpacks(ctx, 0, -1) + if err != nil { + return nil + } + out := make([]idName, len(items)) + for i, x := range items { + out[i] = idName{x.ID, x.Name} + } + return out + }, + } + + // Collect needed source resources and fetch each once. + fetched := make(map[string][]idName) + needed := make(map[string]bool) + for _, res := range rbac.AllResources { + if source, ok := rbac.ResourceScopeSource[res]; ok { + needed[source] = true + } + } + for src := range needed { + if fn, ok := fetchers[src]; ok { + fetched[src] = fn() + } + } + + // Emit ScopeableObjects in stable resource order. + var objects []*v1.ScopeableObject + for _, resource := range rbac.AllResources { + source, ok := rbac.ResourceScopeSource[resource] + if !ok { + continue + } + for _, obj := range fetched[source] { + objects = append(objects, &v1.ScopeableObject{ + Id: obj.id, + Name: obj.name, + Resource: resource, + ScopeSource: source, + }) + } + } + resp.AvailableObjects = objects + } + + return connect.NewResponse(resp), nil +} + +func (s *RoleService) UpdatePermissions(ctx context.Context, req *connect.Request[v1.UpdatePermissionsRequest]) (*connect.Response[v1.UpdatePermissionsResponse], error) { + msg := req.Msg + + if msg.RoleName == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role name is required")) + } + + perms := protoPermsToRbac(msg.Permissions) + + if err := s.enforcer.SetPermissionsForRole(msg.RoleName, perms); err != nil { + s.log.Error("Failed to update permissions for role %s: %v", msg.RoleName, err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to update permissions")) + } + + return connect.NewResponse(&v1.UpdatePermissionsResponse{ + Message: "permissions updated", + }), nil +} + +func (s *RoleService) AssignRole(ctx context.Context, req *connect.Request[v1.AssignRoleRequest]) (*connect.Response[v1.AssignRoleResponse], error) { + msg := req.Msg + + if msg.UserId == "" || msg.RoleName == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user ID and role name are required")) + } + + if err := s.store.AssignRole(ctx, msg.UserId, msg.RoleName, "local"); err != nil { + s.log.Error("Failed to assign role: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to assign role")) + } + + return connect.NewResponse(&v1.AssignRoleResponse{ + Message: "role assigned", + }), nil +} + +func (s *RoleService) UnassignRole(ctx context.Context, req *connect.Request[v1.UnassignRoleRequest]) (*connect.Response[v1.UnassignRoleResponse], error) { + msg := req.Msg + + if msg.UserId == "" || msg.RoleName == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user ID and role name are required")) + } + + if err := s.store.UnassignRole(ctx, msg.UserId, msg.RoleName); err != nil { + s.log.Error("Failed to unassign role: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to unassign role")) + } + + return connect.NewResponse(&v1.UnassignRoleResponse{ + Message: "role unassigned", + }), nil +} + +func (s *RoleService) GetUserRoles(ctx context.Context, req *connect.Request[v1.GetUserRolesRequest]) (*connect.Response[v1.GetUserRolesResponse], error) { + msg := req.Msg + + if msg.UserId == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user ID is required")) + } + + roles, err := s.store.GetUserRoleNames(ctx, msg.UserId) + if err != nil { + s.log.Error("Failed to get user roles: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get user roles")) + } + + return connect.NewResponse(&v1.GetUserRolesResponse{ + Roles: roles, + }), nil +} + +func dbRoleToProto(role *storage.Role, perms []rbac.Permission) *v1.Role { + protoPerms := make([]*v1.Permission, 0, len(perms)) + for _, p := range perms { + protoPerms = append(protoPerms, &v1.Permission{ + Resource: p.Resource, + Action: p.Action, + ObjectId: p.ObjectID, + }) + } + + return &v1.Role{ + Id: role.ID, + Name: role.Name, + Description: role.Description, + IsSystem: role.IsSystem, + IsDefault: role.IsDefault, + Permissions: protoPerms, + CreatedAt: timestamppb.New(role.CreatedAt), + UpdatedAt: timestamppb.New(role.UpdatedAt), + } +} + +func protoPermsToRbac(protoPerms []*v1.Permission) []rbac.Permission { + perms := make([]rbac.Permission, 0, len(protoPerms)) + for _, p := range protoPerms { + objectID := p.ObjectId + if objectID == "" { + objectID = "*" + } + perms = append(perms, rbac.Permission{ + Resource: p.Resource, + Action: p.Action, + ObjectID: objectID, + }) + } + return perms +} diff --git a/internal/rpc/services/user.go b/internal/rpc/services/user.go index 9edacdd..3efcbf1 100644 --- a/internal/rpc/services/user.go +++ b/internal/rpc/services/user.go @@ -10,19 +10,17 @@ import ( "github.com/nickheyer/discopanel/pkg/logger" v1 "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1" "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1/discopanelv1connect" + "google.golang.org/protobuf/types/known/timestamppb" ) -// Compile-time check that UserService implements the interface var _ discopanelv1connect.UserServiceHandler = (*UserService)(nil) -// UserService implements the User service type UserService struct { store *storage.Store authManager *auth.Manager log *logger.Logger } -// NewUserService creates a new user service func NewUserService(store *storage.Store, authManager *auth.Manager, log *logger.Logger) *UserService { return &UserService{ store: store, @@ -31,24 +29,17 @@ func NewUserService(store *storage.Store, authManager *auth.Manager, log *logger } } -// ListUsers lists all users (admin only) func (s *UserService) ListUsers(ctx context.Context, req *connect.Request[v1.ListUsersRequest]) (*connect.Response[v1.ListUsersResponse], error) { - // Check admin permission - user := auth.GetUserFromContext(ctx) - if user == nil || user.Role != storage.RoleAdmin { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("admin access required")) - } - users, err := s.store.ListUsers(ctx) if err != nil { s.log.Error("Failed to list users: %v", err) return nil, connect.NewError(connect.CodeInternal, errors.New("failed to list users")) } - // Convert DB users to proto users - protoUsers := make([]*v1.User, len(users)) - for i, u := range users { - protoUsers[i] = dbUserToProto(u) + protoUsers := make([]*v1.User, 0, len(users)) + for _, user := range users { + roles, _ := s.store.GetUserRoleNames(ctx, user.ID) + protoUsers = append(protoUsers, dbUserToProto(user, roles)) } return connect.NewResponse(&v1.ListUsersResponse{ @@ -56,61 +47,73 @@ func (s *UserService) ListUsers(ctx context.Context, req *connect.Request[v1.Lis }), nil } -// CreateUser creates a new user (admin only) -func (s *UserService) CreateUser(ctx context.Context, req *connect.Request[v1.CreateUserRequest]) (*connect.Response[v1.CreateUserResponse], error) { - // Check admin permission - user := auth.GetUserFromContext(ctx) - if user == nil || user.Role != storage.RoleAdmin { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("admin access required")) +func (s *UserService) GetUser(ctx context.Context, req *connect.Request[v1.GetUserRequest]) (*connect.Response[v1.GetUserResponse], error) { + msg := req.Msg + if msg.Id == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user ID is required")) } + user, err := s.store.GetUser(ctx, msg.Id) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, errors.New("user not found")) + } + + roles, _ := s.store.GetUserRoleNames(ctx, user.ID) + + return connect.NewResponse(&v1.GetUserResponse{ + User: dbUserToProto(user, roles), + }), nil +} + +func (s *UserService) CreateUser(ctx context.Context, req *connect.Request[v1.CreateUserRequest]) (*connect.Response[v1.CreateUserResponse], error) { msg := req.Msg - // Validate role - role := protoRoleToDBRole(msg.Role) - if role != storage.RoleAdmin && role != storage.RoleEditor && role != storage.RoleViewer { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid role")) + if msg.Username == "" || msg.Password == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("username and password are required")) } - // Create user - newUser, err := s.authManager.CreateUser(ctx, msg.Username, msg.Email, msg.Password, role) + user, err := s.authManager.CreateLocalUser(ctx, msg.Username, msg.Email, msg.Password) if err != nil { s.log.Error("Failed to create user: %v", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to create user")) + return nil, connect.NewError(connect.CodeAlreadyExists, errors.New("failed to create user")) + } + + // Assign roles + for _, roleName := range msg.Roles { + if err := s.store.AssignRole(ctx, user.ID, roleName, "local"); err != nil { + s.log.Error("Failed to assign role %s to user %s: %v", roleName, user.ID, err) + } } + // If no roles specified, assign default roles + if len(msg.Roles) == 0 { + defaultRoles, _ := s.store.GetDefaultRoles(ctx) + for _, role := range defaultRoles { + _ = s.store.AssignRole(ctx, user.ID, role.Name, "local") + } + } + + roles, _ := s.store.GetUserRoleNames(ctx, user.ID) + return connect.NewResponse(&v1.CreateUserResponse{ - User: dbUserToProto(newUser), + User: dbUserToProto(user, roles), }), nil } -// UpdateUser updates a user (admin only) func (s *UserService) UpdateUser(ctx context.Context, req *connect.Request[v1.UpdateUserRequest]) (*connect.Response[v1.UpdateUserResponse], error) { - // Check admin permission - currentUser := auth.GetUserFromContext(ctx) - if currentUser == nil || currentUser.Role != storage.RoleAdmin { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("admin access required")) - } - msg := req.Msg - // Get the user to update + if msg.Id == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user ID is required")) + } + user, err := s.store.GetUser(ctx, msg.Id) if err != nil { - s.log.Error("Failed to get user: %v", err) return nil, connect.NewError(connect.CodeNotFound, errors.New("user not found")) } - // Update fields if provided if msg.Email != nil { - if *msg.Email == "" { - user.Email = nil // Allow clearing email - } else { - user.Email = msg.Email - } - } - if msg.Role != nil { - user.Role = protoRoleToDBRole(*msg.Role) + user.Email = msg.Email } if msg.IsActive != nil { user.IsActive = *msg.IsActive @@ -121,24 +124,48 @@ func (s *UserService) UpdateUser(ctx context.Context, req *connect.Request[v1.Up return nil, connect.NewError(connect.CodeInternal, errors.New("failed to update user")) } + // Update roles if provided + if len(msg.Roles) > 0 { + // Get current roles + currentRoles, _ := s.store.GetUserRoleNames(ctx, user.ID) + + // Build sets for comparison + currentSet := make(map[string]bool) + for _, r := range currentRoles { + currentSet[r] = true + } + desiredSet := make(map[string]bool) + for _, r := range msg.Roles { + desiredSet[r] = true + } + + // Remove roles not in desired set + for _, r := range currentRoles { + if !desiredSet[r] { + _ = s.store.UnassignRole(ctx, user.ID, r) + } + } + + // Add roles not in current set + for _, r := range msg.Roles { + if !currentSet[r] { + _ = s.store.AssignRole(ctx, user.ID, r, "local") + } + } + } + + roles, _ := s.store.GetUserRoleNames(ctx, user.ID) + return connect.NewResponse(&v1.UpdateUserResponse{ - User: dbUserToProto(user), + User: dbUserToProto(user, roles), }), nil } -// DeleteUser deletes a user (admin only) func (s *UserService) DeleteUser(ctx context.Context, req *connect.Request[v1.DeleteUserRequest]) (*connect.Response[v1.DeleteUserResponse], error) { - // Check admin permission - currentUser := auth.GetUserFromContext(ctx) - if currentUser == nil || currentUser.Role != storage.RoleAdmin { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("admin access required")) - } - msg := req.Msg - // Prevent self-deletion - if currentUser.ID == msg.Id { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("cannot delete your own account")) + if msg.Id == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user ID is required")) } if err := s.store.DeleteUser(ctx, msg.Id); err != nil { @@ -147,6 +174,23 @@ func (s *UserService) DeleteUser(ctx context.Context, req *connect.Request[v1.De } return connect.NewResponse(&v1.DeleteUserResponse{ - Message: "User deleted successfully", + Message: "user deleted", }), nil -} \ No newline at end of file +} + +func dbUserToProto(user *storage.User, roles []string) *v1.User { + protoUser := &v1.User{ + Id: user.ID, + Username: user.Username, + Email: user.Email, + AuthProvider: user.AuthProvider, + IsActive: user.IsActive, + Roles: roles, + CreatedAt: timestamppb.New(user.CreatedAt), + UpdatedAt: timestamppb.New(user.UpdatedAt), + } + if user.LastLogin != nil { + protoUser.LastLogin = timestamppb.New(*user.LastLogin) + } + return protoUser +} diff --git a/internal/ws/hub.go b/internal/ws/hub.go index b359915..79abb52 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -10,6 +10,7 @@ import ( "github.com/nickheyer/discopanel/internal/auth" storage "github.com/nickheyer/discopanel/internal/db" "github.com/nickheyer/discopanel/internal/docker" + "github.com/nickheyer/discopanel/internal/rbac" "github.com/nickheyer/discopanel/pkg/logger" v1 "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1" "google.golang.org/protobuf/proto" @@ -31,8 +32,9 @@ const ( // Hub manages WebSocket connections and log subscriptions type Hub struct { - logStreamer *logger.LogStreamer + logStreamer *logger.LogStreamer authManager *auth.Manager + enforcer *rbac.Enforcer store *storage.Store docker *docker.Client log *logger.Logger @@ -55,7 +57,7 @@ type Client struct { send chan []byte // Authentication - user *storage.User + user *auth.AuthenticatedUser authenticated bool // Subscriptions: serverId -> log channel @@ -64,10 +66,11 @@ type Client struct { } // NewHub creates a new WebSocket hub -func NewHub(logStreamer *logger.LogStreamer, authManager *auth.Manager, store *storage.Store, docker *docker.Client, log *logger.Logger) *Hub { +func NewHub(logStreamer *logger.LogStreamer, authManager *auth.Manager, enforcer *rbac.Enforcer, store *storage.Store, docker *docker.Client, log *logger.Logger) *Hub { return &Hub{ - logStreamer: logStreamer, + logStreamer: logStreamer, authManager: authManager, + enforcer: enforcer, store: store, docker: docker, log: log, @@ -217,15 +220,30 @@ func (c *Client) handleAuth(msg *v1.AuthMessage) { } ctx := context.Background() - user, err := c.hub.authManager.ValidateSession(ctx, msg.Token) - if err != nil { - c.sendAuthFail("invalid token") - return - } - c.user = user - c.authenticated = true - c.sendAuthOk() + if msg.Token != "" { + user, err := c.hub.authManager.ValidateSession(ctx, msg.Token) + if err != nil { + // Try anonymous access + if c.hub.authManager.IsAnonymousAccessEnabled() { + c.user = c.hub.authManager.AnonymousUser() + c.authenticated = true + c.sendAuthOk() + return + } + c.sendAuthFail("invalid token") + return + } + c.user = user + c.authenticated = true + c.sendAuthOk() + } else if c.hub.authManager.IsAnonymousAccessEnabled() { + c.user = c.hub.authManager.AnonymousUser() + c.authenticated = true + c.sendAuthOk() + } else { + c.sendAuthFail("authentication required") + } } // handleSubscribe subscribes to server logs @@ -240,6 +258,15 @@ func (c *Client) handleSubscribe(msg *v1.SubscribeMessage) { return } + // Check permission + if c.hub.enforcer != nil && c.user != nil { + allowed, err := c.hub.enforcer.Enforce(c.user.Roles, rbac.ResourceServers, rbac.ActionRead, msg.ServerId) + if err != nil || !allowed { + c.sendError("permission denied") + return + } + } + // Get server to find container ID ctx := context.Background() server, err := c.hub.store.GetServer(ctx, msg.ServerId) @@ -248,8 +275,17 @@ func (c *Client) handleSubscribe(msg *v1.SubscribeMessage) { return } + tail := int(msg.Tail) + if tail <= 0 { + tail = 500 + } + + // If server has no container yet (just created, never started), + // send empty logs and confirm subscription without starting streaming. + // The client will re-subscribe when the server status changes. if server.ContainerID == "" { - c.sendError("server has no container") + c.sendLogs(msg.ServerId, nil) + c.sendSubscribed(msg.ServerId) return } @@ -268,11 +304,7 @@ func (c *Client) handleSubscribe(msg *v1.SubscribeMessage) { } c.subscriptionsMu.Unlock() - // Always send initial logs - tail := int(msg.Tail) - if tail <= 0 { - tail = 500 - } + // Send initial logs logs := c.hub.logStreamer.GetLogs(server.ContainerID, tail) c.sendLogs(msg.ServerId, logs) @@ -325,6 +357,15 @@ func (c *Client) handleCommand(msg *v1.CommandMessage) { return } + // Check command permission + if c.hub.enforcer != nil && c.user != nil { + allowed, err := c.hub.enforcer.Enforce(c.user.Roles, rbac.ResourceServers, rbac.ActionCommand, msg.ServerId) + if err != nil || !allowed { + c.sendCommandResult(msg.ServerId, false, "", "permission denied") + return + } + } + ctx := context.Background() server, err := c.hub.store.GetServer(ctx, msg.ServerId) if err != nil { @@ -396,12 +437,18 @@ func (c *Client) sendMessage(msg *v1.WebSocketServerMessage) { } func (c *Client) sendAuthOk() { + userId := "" + username := "" + if c.user != nil { + userId = c.user.ID + username = c.user.Username + } c.sendMessage(&v1.WebSocketServerMessage{ Type: v1.WSMessageType_WS_MESSAGE_TYPE_AUTH_OK, Payload: &v1.WebSocketServerMessage_AuthOk{ AuthOk: &v1.AuthOkMessage{ - UserId: c.user.ID, - Username: c.user.Username, + UserId: userId, + Username: username, }, }, }) diff --git a/oidc/authelia/config/configuration.yml b/oidc/authelia/config/configuration.yml new file mode 100644 index 0000000..4fe33ee --- /dev/null +++ b/oidc/authelia/config/configuration.yml @@ -0,0 +1,176 @@ +--- +# DiscoPanel - Authelia Configuration +# +# This config is ready to use for development/testing. +# For production, change ALL secrets and generate new keys. + +server: + address: 'tcp://:9091' + tls: + certificate: '/config/tls.crt' + key: '/config/tls.key' + +log: + level: 'info' + +totp: + issuer: 'discopanel' + +identity_validation: + reset_password: + jwt_secret: 'discopanel-dev-jwt-reset-secret-change-me-in-production' + +# User accounts are stored in ./users_database.yml +authentication_backend: + file: + path: '/config/users_database.yml' + password: + algorithm: 'argon2' + argon2: + variant: 'argon2id' + iterations: 3 + memory: 65536 + parallelism: 4 + key_length: 32 + salt_length: 16 + +# Authelia needs a cookie domain for its login portal. ../docker-compose.yaml for instruction +session: + secret: 'discopanel-dev-session-secret-change-me-in-production' + cookies: + - name: 'authelia_session' + domain: 'traefik.me' + authelia_url: 'https://authelia.traefik.me:9091' + expiration: '1 hour' + inactivity: '5 minutes' + +storage: + encryption_key: 'discopanel-dev-storage-encryption-key-change-me' + local: + path: '/config/db.sqlite3' + +notifier: + filesystem: + filename: '/config/notification.txt' + +access_control: + default_policy: 'one_factor' + +regulation: + max_retries: 5 + find_time: '2 minutes' + ban_time: '5 minutes' + +identity_providers: + oidc: + hmac_secret: 'discopanel-dev-hmac-secret-must-be-at-least-sixty-four-characters-long-so-here-is-padding' + enable_client_debug_messages: true + minimum_parameter_entropy: 8 + enforce_pkce: 'public_clients_only' + + jwks: + - key_id: 'discopanel-dev' + algorithm: 'RS256' + use: 'sig' + key: | + -----BEGIN PRIVATE KEY----- + MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCwvYB+KRmfkaiV + KN/wRNdPekLl4XXu8ATxhxerg2KOE31lUcrlyezH/OLEOmd0VToPnMXBUKgIMDWP + bMdsAkeXrwAdzYOZM+Ls6bFy9lr70DD4SU4yc//6vNQfqBbQ5oR3IAURS6u9Atu9 + XF+84RU/d46Sc3BhT7Z7U3VFDEt3MVLpFNLg6/YhRyguCQD24ZCE5orIwtmkSGpk + 8dxcM2Bpj9y9wdkFLEeCWSI2owpCGEENldV09oPK+a8Xqz59XBvJki4Ax3BxOAhu + EKh5qe7d40ltQn3l1a8GM6gmKNwtKK4jNo/Gl5P0+4/wwoFdxT55w1GHijHW1OnT + rDwYEb15AgMBAAECggEAAlISqFVo0TgL4x18xz5YJ2J/E16g+kirf/JapLVea2gl + Gtn2lIrQsZWH8rSjnBrsXr0buZySAD2FzoLKoYfsIbk6Aqoqoq3UOnEdE9nZOvoy + Umg//xiX0VZ+YIYH+qk0Lw48EsyQDjTF5tgaJ7Q637D1rcWXQafWyQrA/O2a5g85 + lGY4ExRXOsei+6bB1ygaYUQ1tEDyjV3VhkRNV6pfnSQljBvxVC1Xynod2mzFdzjk + AY6VRoYZ9Y5pw+V2vjiX+aI6gfwHgh4K5ogsEMUEWaPautxeKiPku+DXJ1Tb3xh0 + RVDb8VoNxxlVUf21j/0ZUwgrFX0sYLXG4r8sj3H2wwKBgQD4yrajiSkpAk71o+t8 + W2zfXYDUzjxojeCb7JraK4OzdkqGs+zSJERXcm4Q3scffBipiQ/s0sYErGCCKWo+ + Z+FU9mU0edJf+QImk3tDXKkDVXaReK69tq/T7ug6vD0lKOFCDY9HF7D/+Jh6jpiH + TX1dnYgN82UYPSO05fqsd1QgwwKBgQC13GHpV1cDhyKDX8xzhsvZWvyznAX6lqmW + NlicwHbFCyRn9g0f1UCaSV0PsdDxK+91eCZ16jiu0lPDE6vDCoVHk00QEuux+B74 + JO/0hr2mKxjcof3Na6wRAbQjbvl9BHrH7qRhdjpJRH+E2ZoectrAPrToWQOrtVBX + bn5yvBqFEwKBgCoWqSUrXBI6+L6nl3v3P4jeGaBmr2OEtP3L3jqQZ/xhQ6RcJfE6 + /3DHxAUImykhZk6wCEipM6SwwLbkaLvb+QvVjzN8dHGV/54lDxJLR7BvsdpUT0N6 + 923kGddt5u41Zz40awu8302+cZUyMG2bV10R/GVXyr96AGNnEKxCl7HfAoGAC81C + eV8WoX76iWYFIZYk0nUqIwnEBZATb1EVjQ6cZosjkK+SCHfRWnHaXTNf6Na+EnR6 + onpRtV6m2ukC44RiQ9PWU2225/S/JcFX5Rl9YzQ2x9KnYtZS80OWChqgjDFnOmRN + PJnsjGaqk9d/PeycL4+iM9Xa/CCnFxVvlUiJvAsCgYBbZPSgQtHlODNluqpr6i68 + yKfDO2hrrvSA1Y8WteM52Gpmn5b1QlopZ0/4gYmWM4qMqQmtH3uWC7NDvQmqMeFW + VPt3B7EmnT9ugKE6L9sgHtY54fIrSr8DDLl6WNLWTey4ivfhlU6o/SxMjNb8VXL3 + v0jKQTZz1R2wLqdxs1KRbg== + -----END PRIVATE KEY----- + certificate_chain: | + -----BEGIN CERTIFICATE----- + MIIDMTCCAhmgAwIBAgIUCQjLyj/HNDk2e4KdiOgmBH5dtOswDQYJKoZIhvcNAQEL + BQAwKDERMA8GA1UEAwwIYXV0aGVsaWExEzARBgNVBAoMCkRpc2NvUGFuZWwwHhcN + MjYwMjE2MjIxMjE1WhcNMzYwMjE0MjIxMjE1WjAoMREwDwYDVQQDDAhhdXRoZWxp + YTETMBEGA1UECgwKRGlzY29QYW5lbDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC + AQoCggEBALC9gH4pGZ+RqJUo3/BE1096QuXhde7wBPGHF6uDYo4TfWVRyuXJ7Mf8 + 4sQ6Z3RVOg+cxcFQqAgwNY9sx2wCR5evAB3Ng5kz4uzpsXL2WvvQMPhJTjJz//q8 + 1B+oFtDmhHcgBRFLq70C271cX7zhFT93jpJzcGFPtntTdUUMS3cxUukU0uDr9iFH + KC4JAPbhkITmisjC2aRIamTx3FwzYGmP3L3B2QUsR4JZIjajCkIYQQ2V1XT2g8r5 + rxerPn1cG8mSLgDHcHE4CG4QqHmp7t3jSW1CfeXVrwYzqCYo3C0oriM2j8aXk/T7 + j/DCgV3FPnnDUYeKMdbU6dOsPBgRvXkCAwEAAaNTMFEwHQYDVR0OBBYEFNMzG9im + j+WQsYBQnEk5SbXRzhozMB8GA1UdIwQYMBaAFNMzG9imj+WQsYBQnEk5SbXRzhoz + MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHy1bE1X/J4jiTSd + dzzV+7imfRKWjXA3l1R7ztfXil5KgQxoG9KyCGIsS5yRD94BH6k9n15wxFwa3xbw + aPzr6jAt+I/S4WLoAtWZkRDdnOMpCslydStvBJso9Sf3yP0omcfRab7B49JYg+4g + SaHFt0W5ncgaHNJat2uwtNa3XeMo3uyatZw99y16X2XlOzkhPWl0PHb2Wet6JGbW + rwfjJuT9gmr5LpIHXXIpoz8COQ/tWqDwcwxOh7Vhz4h8ohMaBF7DwtwEUU0HFe1L + KhlYOOn3iEYMCHoRCwqfuCty3kXbUigqOdC+3GD3sPJHsetZpju6mzgRUK6HwzB7 + ry2+WVw= + -----END CERTIFICATE----- + + lifespans: + access_token: '1h' + authorize_code: '1m' + id_token: '1h' + refresh_token: '90m' + + # This policy puts "groups" directly in the ID token. Without it, groups only show up in the UserInfo endpoint, and Discopanel wouldn't read roles. + claims_policies: + discopanel: + id_token: + - 'groups' + - 'email' + - 'email_verified' + - 'preferred_username' + - 'name' + + cors: + endpoints: + - 'authorization' + - 'token' + - 'revocation' + - 'introspection' + - 'userinfo' + allowed_origins_from_client_redirect_uris: true + + clients: + - client_id: 'discopanel' + client_name: 'DiscoPanel' + # Plaintext secret: discopanel-dev-secret + client_secret: '$pbkdf2-sha512$310000$rjIc/fKRSBnSAe/FKJa.aQ$KIJjfQsCOexmk52nLb73HmJrcMkiDl3GRuWWpmdJN.talWSB.p7cq7zbiiVj4P0xV3YMeJtdlMfzWSVU2XSblw' + public: false + authorization_policy: 'one_factor' + claims_policy: 'discopanel' + redirect_uris: + - 'http://localhost:8080/api/v1/auth/oidc/callback' + - 'http://localhost:5173/api/v1/auth/oidc/callback' + scopes: + - 'openid' + - 'profile' + - 'email' + - 'groups' + - 'offline_access' + grant_types: + - 'authorization_code' + - 'refresh_token' + response_types: + - 'code' + response_modes: + - 'query' + - 'form_post' + token_endpoint_auth_method: 'client_secret_basic' diff --git a/oidc/authelia/config/tls.crt b/oidc/authelia/config/tls.crt new file mode 100644 index 0000000..16175ab --- /dev/null +++ b/oidc/authelia/config/tls.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTDCCAjSgAwIBAgIUCw6dx3TcreCmZh2d9Dd3I4DgsPUwDQYJKoZIhvcNAQEL +BQAwHjEcMBoGA1UEAwwTYXV0aGVsaWEudHJhZWZpay5tZTAeFw0yNjAyMTcwMDMy +NDdaFw0zNjAyMTUwMDMyNDdaMB4xHDAaBgNVBAMME2F1dGhlbGlhLnRyYWVmaWsu +bWUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDRRBUQzQeTbP3dHpwG +E10k7+Os+bv8gClOilNzZul2Pbe1peCWHaHx6lsmB+6DNcfRKBBzBVps+9T74pdl +huFiG6Q6lLL8mUfCUlaJYm12zZRN0q/8O9hM+k/lPp4XPIsm+n4jslIVNWjfbmla +v7PgftoxKAY9h2SI3dpdMOKVlUYevEdvwQo1f0Ag+hLzfK9XDK7q6M4rbN+oV/yy +BktuYz6jWGqF0k5Ao8gIz2NX2VBco/nmP8vwMt99/5ZhMkgeZzmcObBMf2Je4fs2 +eQEVVOhNua1EoRYkImN6mLzRmXl10fHYK4rzBzMo6gLMhpNtJ+6VhmyeYvkdfzZO +r9DlAgMBAAGjgYEwfzAdBgNVHQ4EFgQUEfZyRhx1c6O21Etj6pnh73lP9SowHwYD +VR0jBBgwFoAUEfZyRhx1c6O21Etj6pnh73lP9SowDwYDVR0TAQH/BAUwAwEB/zAs +BgNVHREEJTAjghNhdXRoZWxpYS50cmFlZmlrLm1lggwqLnRyYWVmaWsubWUwDQYJ +KoZIhvcNAQELBQADggEBAE82joNb/45do0s9rf9B2CIacH4g98umweyKVud5IcnD +jfHHTBxL0Hhs6Emvg7UfARbAnt66PyJYk9KUp+FzrWYkjVuXERAUesBfw5tTjUMG +MvRmRc9ro19S4IG4vn23y5vOaElNdMFM3dFci5WVYKJbc6Knswpb3QhzQ6p6zwzP +/hWeWkm1glxQ0o8fAyF0DpHpQcQzNOxw6wNK1DPr14rJYT4DqwaT37yM8zxOoibn +9PerOh3AZgKFvdZoaWSSWp9PRLGQ3VPM9E72+ExWF3eXT5SktE2PoUJdt7zISGLe +g3q1V7Q8Y8H/0JtOqOqULw3XFlbGHAxukZ+W9WccHe4= +-----END CERTIFICATE----- diff --git a/oidc/authelia/config/tls.key b/oidc/authelia/config/tls.key new file mode 100644 index 0000000..c8bfd83 --- /dev/null +++ b/oidc/authelia/config/tls.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRRBUQzQeTbP3d +HpwGE10k7+Os+bv8gClOilNzZul2Pbe1peCWHaHx6lsmB+6DNcfRKBBzBVps+9T7 +4pdlhuFiG6Q6lLL8mUfCUlaJYm12zZRN0q/8O9hM+k/lPp4XPIsm+n4jslIVNWjf +bmlav7PgftoxKAY9h2SI3dpdMOKVlUYevEdvwQo1f0Ag+hLzfK9XDK7q6M4rbN+o +V/yyBktuYz6jWGqF0k5Ao8gIz2NX2VBco/nmP8vwMt99/5ZhMkgeZzmcObBMf2Je +4fs2eQEVVOhNua1EoRYkImN6mLzRmXl10fHYK4rzBzMo6gLMhpNtJ+6VhmyeYvkd +fzZOr9DlAgMBAAECggEAHxbuzEKxq/Dm3FmGU46/6VNsb0/g4lgGCwGY+U2iRKtR +pj6BGbxISYEITqOiB0NPrt61Zuk2MHfPgiZ9WJuL04AIy5045Dc/hnqmGZ4SZjKP +pGo3NBGOBo2vnf7KDOi1QbK4V8RP1o/LR1qHc3CEoEcoUmJAXxbE1GKlZO/00cUJ +R6n07R2JVDKlZzzGrRb5eISRMAFwn5W9dzCtdrlaLKRSfSEl+E/3QJE6g2EbLByA +wwTGtMoZvTjhWNhCaSqhfFyxRlbf4bBA6WN+mJrgxbF0iaKNQ5UawUa+Lej77aRy +kn1zgttEl2Mnoqk7mbou1ZgRnlizDWvTm56ir8iQUQKBgQDvpAxllJKmsrk/PNiI +cWKc11EkQIfs2rbB0LQQTJqRChKz0r/oH4egXtjZLlcrhsmhk9ZNV3QBoz0NliE3 +WCxCAiGNu/MsmdUFyteI+xuxTwbF9xCZxLiDg2bCeBu5y9pqfiN62egzToLligd7 +c4Kk0gJ5Dpb0spzMRhK9thtdDQKBgQDfjTRARZN5EJWb4JBJ5as0/s4FJhHT0DIZ +WR2ThBHmv3wr9vrKD8pAEbY9C57yLKXODyK9EWXF+ukNjudy3Kqc5mFeBa+qsEGR +qOS6xWplQQVc2c6Bs9O9b4oD/CkdnUMOT9PaaVHvrUCwzU7tVq5VjFvyAz1PnGY4 +ARQKUBw9OQKBgQCOPK3LAUOGRCCmA0R2v+4LL9YOkWrcT/kX0vt9jSpVGkh9iYK0 +kTpcGs/VIKdGw4scJ3aUk2rcqfpL/SccBW7HgyJNURiGCYyiEoKZ4InQVRqtF/c3 +fccS8ERm+wlh3zh16wa+HWawRVJ2UdYdFTOfBrPHDLzW4skkihcHmXZmZQKBgBTx +qb+LxTFGeH3OIDaMKeohJTQeSPVLQCZXzwmPCg5QSlXkIcLkj9JI1oYJnK6buD0B +9gM4qgxOYZ8/kDeWrPVeMCka50ZalQoMhMFq1Xj/Cn2UemB0dJX+6TNOYJvBrBKf +L/36eA64cKMf2RErWdHyAHtACnJ2+KyujS4aK0shAoGBAMTjIP+Adx3z0HzSVgHj +NEBpbgZ+cGcSUp25oaLffj2O5OCSARnuvbI8mso3/x820Jn8igie1oLPHGJhSBjq +8gbzdMSuFZdz9ZT+9HOxdJT2IueiC5DncK/5a+d1Qr/u5+vGyzWOvP0qMO+iJns2 +V9p/Ek7ccIa0u9eTawNnraHJ +-----END PRIVATE KEY----- diff --git a/oidc/authelia/config/users_database.yml b/oidc/authelia/config/users_database.yml new file mode 100644 index 0000000..fd27bff --- /dev/null +++ b/oidc/authelia/config/users_database.yml @@ -0,0 +1,21 @@ +--- +# DiscoPanel - Authelia Users +# +# Do NOT use default admin in production. +# Default login is "admin" / "admin" +# +# To generate a new password hash: +# +# docker run --rm authelia/authelia:latest \ +# authelia crypto hash generate argon2 \ +# --password 'your-password-here' + +users: + admin: # username + disabled: false + displayname: 'Discopanel Admin' + password: '$argon2id$v=19$m=65536,t=3,p=4$9X6yDiM/+/Wi1dBCzPGftw$7hqUauLP/Hh9Z5KlLQn/2IVNdX+/vbWLFgz/i+TtchI' # password (hash of "admin") + email: 'admin@discopanel.local' + groups: + - 'admin' + diff --git a/oidc/authelia/docker-compose.yaml b/oidc/authelia/docker-compose.yaml new file mode 100644 index 0000000..047d822 --- /dev/null +++ b/oidc/authelia/docker-compose.yaml @@ -0,0 +1,72 @@ +# DiscoPanel + Authelia (OIDC) +# +# This is a complete docker-compose with OIDC authentication pre-configured using Authelia. +# +# NOTE #1: Default users should be changed (SEE oidc/authelia/config/users_database.yml). +# Passwords are stored as hash, see instructions. Use same hash cmd for secret below (when setting in oidc/authelia/config/configuration.yaml) if needed. +# +# NOTE #2: Authelia is generally intended to be used with a proxy like traefik. Plenty of guides online for that. +# Feel free to throw away the example configuration.yml and users_database.yml !!!! Just make sure groups are included in claims. + +services: + discopanel: + build: + context: ../../ + dockerfile: docker/Dockerfile.discopanel + image: nickheyer/discopanel:dev + container_name: discopanel + restart: unless-stopped + network_mode: host + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /opt/discopanel/data:/app/data + - /opt/discopanel/backups:/app/backups + - /tmp/discopanel:/app/tmp + environment: + - DISCOPANEL_DATA_DIR=/app/data + - DISCOPANEL_HOST_DATA_PATH=/opt/discopanel/data + - TZ=UTC + + # ------------------------------------ AUTH CONFIG STARTS HERE FOR DISCOPANEL + AUTHELIA ------------------------------------ + - DISCOPANEL_AUTH_LOCAL_ENABLED=true + - DISCOPANEL_AUTH_OIDC_ENABLED=true + + # MUST MATCH oidc/authelia/config/configuration.yaml + - DISCOPANEL_AUTH_OIDC_ISSUER_URI=https://authelia.traefik.me:9091 + + # ONLY CHANGE THIS IF YOUR CLIENT NAME IS DIFFERENT + - DISCOPANEL_AUTH_OIDC_CLIENT_ID=discopanel + + # YOU SHOULD CHANGE THIS HERE AS WELL AS THE HASHED ONE IN oidc/authelia/config/configuration.yaml (inside the identity_providers.client) + - DISCOPANEL_AUTH_OIDC_CLIENT_SECRET=discopanel-dev-secret + + # YOU SHOULD CHANGE "localhost:8080" TO WHATEVER YOUR PUBLIC DOMAIN IS FOR DISCOPANEL (ie: https://mypanel.com/api/v1/auth/oidc/callback) + - DISCOPANEL_AUTH_OIDC_REDIRECT_URL=http://localhost:8080/api/v1/auth/oidc/callback + + - DISCOPANEL_AUTH_OIDC_ROLE_CLAIM=groups + + # SKIPPING TLS VERIFY HERE BECAUSE TLS CERTS ARE SELF SIGNED. IF USING YOUR OWN CERTS (in authelia mounts), REMOVE THIS. + - DISCOPANEL_AUTH_OIDC_SKIP_TLS_VERIFY=true + + depends_on: + authelia: + condition: service_healthy + + authelia: + image: authelia/authelia:latest + container_name: authelia + volumes: + - ./config/configuration.yml:/config/configuration.yml:ro + - ./config/users_database.yml:/config/users_database.yml:ro + - ./config/tls.crt:/config/tls.crt:ro # THE INCLUDED CERT IS SELF SIGNED + - ./config/tls.key:/config/tls.key:ro + ports: + - "9091:9091" + healthcheck: + test: ["CMD", "wget", "--no-check-certificate", "--spider", "https://localhost:9091/api/health"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + environment: + - TZ=UTC diff --git a/oidc/keycloak/config/realm.json b/oidc/keycloak/config/realm.json new file mode 100644 index 0000000..2f14ae2 --- /dev/null +++ b/oidc/keycloak/config/realm.json @@ -0,0 +1,106 @@ +{ + "realm": "discopanel", + "enabled": true, + "registrationAllowed": false, + "resetPasswordAllowed": true, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "sslRequired": "none", + "roles": { + "realm": [ + { + "name": "admin", + "description": "DiscoPanel administrator" + }, + { + "name": "user", + "description": "DiscoPanel user" + } + ] + }, + "groups": [ + { + "name": "admin", + "realmRoles": ["admin"] + }, + { + "name": "user", + "realmRoles": ["user"] + } + ], + "defaultGroups": ["user"], + "clients": [ + { + "clientId": "discopanel", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "secret": "discopanel-dev-secret", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:8080/*", + "http://localhost:5173/*" + ], + "webOrigins": [ + "http://localhost:8080", + "http://localhost:5173" + ], + "defaultClientScopes": [ + "openid", + "profile", + "email", + "roles" + ], + "protocolMappers": [ + { + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "config": { + "full.path": "false", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "name": "realm-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + } + ], + "users": [ + { + "username": "admin", + "enabled": true, + "email": "admin@discopanel.local", + "emailVerified": true, + "firstName": "Disco", + "lastName": "Admin", + "credentials": [ + { + "type": "password", + "value": "admin", + "temporary": false + } + ], + "groups": ["admin", "user"] + } + ] +} diff --git a/oidc/keycloak/docker-compose.yaml b/oidc/keycloak/docker-compose.yaml new file mode 100644 index 0000000..12369b2 --- /dev/null +++ b/oidc/keycloak/docker-compose.yaml @@ -0,0 +1,102 @@ +# DiscoPanel + Keycloak (OIDC) +# +# This is a complete docker-compose with OIDC authentication pre-configured using Keycloak. +# +# Keycloak takes ~30-60 seconds to start. DiscoPanel waits for it. +# +# Keycloak Admin UI (See KC_BOOTSTRAP_ADMIN_USERNAME/KC_BOOTSTRAP_ADMIN_PASSWORD below): http://localhost:8180/admin + +services: + discopanel: + build: + context: ../../ + dockerfile: docker/Dockerfile.discopanel + image: nickheyer/discopanel:dev + container_name: discopanel + restart: unless-stopped + network_mode: host + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /opt/discopanel/data:/app/data + - /opt/discopanel/backups:/app/backups + - /tmp/discopanel:/app/tmp + environment: + - DISCOPANEL_DATA_DIR=/app/data + - DISCOPANEL_HOST_DATA_PATH=/opt/discopanel/data + - TZ=UTC + + # ------------------------------------ AUTH CONFIG STARTS HERE FOR DISCOPANEL + KEYCLOAK ------------------------------------ + - DISCOPANEL_AUTH_LOCAL_ENABLED=true + - DISCOPANEL_AUTH_OIDC_ENABLED=true + + # ONLY CHANGE THIS IF YOU ARE HOSTING OIDC ON ANOTHER MACHINE OR WITH A DIFFERENT "realm" NAME + - DISCOPANEL_AUTH_OIDC_ISSUER_URI=http://localhost:8180/realms/discopanel + + # ONLY CHANGE THIS IF YOUR CLIENT NAME IS DIFFERENT + - DISCOPANEL_AUTH_OIDC_CLIENT_ID=discopanel + + # YOU SHOULD CHANGE THIS HERE AS WELL AS IN oidc/keycloak/config/realm.json (inside the clients object) + - DISCOPANEL_AUTH_OIDC_CLIENT_SECRET=discopanel-dev-secret + + # YOU SHOULD CHANGE "localhost:8080" TO WHATEVER YOUR PUBLIC DOMAIN IS FOR DISCOPANEL (ie: https://mypanel.com/api/v1/auth/oidc/callback) + - DISCOPANEL_AUTH_OIDC_REDIRECT_URL=http://localhost:8080/api/v1/auth/oidc/callback + + - DISCOPANEL_AUTH_OIDC_ROLE_CLAIM=groups + + depends_on: + keycloak: + condition: service_healthy + + keycloak: + image: quay.io/keycloak/keycloak:26.1 + container_name: keycloak + command: start-dev --import-realm + volumes: + # THIS IS AN EXAMPLE REALM CONFIG, MODIFY AS NEEDED (WHICH MAY REQUIRE MODIFYING DISCOPANEL CONFIG TO MATCH, SEE ABOVE) + - ./config/*:/opt/keycloak/data/import/.:ro + environment: + # KEYCLOAK ADMIN LOGIN CREDENTIALS - CHANGE THESE!!!!! + - KC_BOOTSTRAP_ADMIN_USERNAME=admin + - KC_BOOTSTRAP_ADMIN_PASSWORD=admin + + # KEYCLOAK DATABASE CONFIG, DONT CHANGE THESE UNLESS YOU KNOW WHAT YOU ARE DOING OR USING EXISTING DATABASE + - KC_DB=postgres + - KC_DB_URL_HOST=keycloak-db + - KC_DB_URL_DATABASE=keycloak + - KC_DB_USERNAME=keycloak + - KC_DB_PASSWORD=keycloak + + # MISC KEYCLOAK CONFIGS + - KC_HOSTNAME_STRICT=false + - KC_HTTP_ENABLED=true + - KC_HEALTH_ENABLED=true + - KC_PROXY_HEADERS=xforwarded + ports: + - "8180:8080" + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3; timeout 2 cat <&3 | grep -q '200 OK'"] + interval: 20s + timeout: 5s + retries: 12 + start_period: 30s + depends_on: + keycloak-db: + condition: service_healthy + + keycloak-db: + image: postgres:17-alpine + container_name: keycloak-db + environment: + - POSTGRES_DB=keycloak + - POSTGRES_USER=keycloak + - POSTGRES_PASSWORD=keycloak + volumes: + - keycloak-db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U keycloak"] + interval: 10s + timeout: 3s + retries: 5 + +volumes: + keycloak-db-data: diff --git a/proto/discopanel/v1/auth.proto b/proto/discopanel/v1/auth.proto index d836cb5..36c307e 100644 --- a/proto/discopanel/v1/auth.proto +++ b/proto/discopanel/v1/auth.proto @@ -7,26 +7,26 @@ import "google/protobuf/timestamp.proto"; option go_package = "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1;discopanelv1"; -// Authentication and authorization service +// Authentication service service AuthService { - // Check if auth is enabled + // Check auth system status rpc GetAuthStatus(GetAuthStatusRequest) returns (GetAuthStatusResponse); - // Authenticate user credentials + // Authenticate with local credentials rpc Login(LoginRequest) returns (LoginResponse); - // Invalidate session token + // Invalidate session rpc Logout(LogoutRequest) returns (LogoutResponse); - // Create new user account + // Register new local account rpc Register(RegisterRequest) returns (RegisterResponse); - // Reset password with recovery key - rpc ResetPassword(ResetPasswordRequest) returns (ResetPasswordResponse); - // Get auth configuration - rpc GetAuthConfig(GetAuthConfigRequest) returns (GetAuthConfigResponse); - // Modify auth configuration - rpc UpdateAuthConfig(UpdateAuthConfigRequest) returns (UpdateAuthConfigResponse); // Get authenticated user info rpc GetCurrentUser(GetCurrentUserRequest) returns (GetCurrentUserResponse); - // Change user's own password + // Change own password (local auth only) rpc ChangePassword(ChangePasswordRequest) returns (ChangePasswordResponse); + // Get OIDC login redirect URL + rpc GetOIDCLoginURL(GetOIDCLoginURLRequest) returns (GetOIDCLoginURLResponse); + // Get full auth configuration + rpc GetAuthConfig(GetAuthConfigRequest) returns (GetAuthConfigResponse); + // Update mutable auth settings + rpc UpdateAuthSettings(UpdateAuthSettingsRequest) returns (UpdateAuthSettingsResponse); } // Empty auth status request @@ -34,12 +34,14 @@ message GetAuthStatusRequest {} // Auth system state message GetAuthStatusResponse { - bool enabled = 1; - bool first_user_setup = 2; + bool local_auth_enabled = 1; + bool oidc_enabled = 2; bool allow_registration = 3; + bool first_user_setup = 4; + bool anonymous_access_enabled = 5; } -// User credentials +// Local login credentials message LoginRequest { string username = 1; string password = 2; @@ -60,64 +62,28 @@ message LogoutResponse { string message = 1; } -// New account info +// New local account message RegisterRequest { string username = 1; string email = 2; string password = 3; } -// Created user account +// Created user message RegisterResponse { User user = 1; } -// Password reset credentials -message ResetPasswordRequest { - string username = 1; - string recovery_key = 2; - string new_password = 3; -} - -// Reset confirmation -message ResetPasswordResponse { - string message = 1; -} - -// Empty config request -message GetAuthConfigRequest {} - -// Current auth settings -message GetAuthConfigResponse { - bool enabled = 1; - int32 session_timeout = 2; - bool require_email_verify = 3; - bool allow_registration = 4; -} - -// Auth settings to update -message UpdateAuthConfigRequest { - optional bool enabled = 1; - optional int32 session_timeout = 2; - optional bool require_email_verify = 3; - optional bool allow_registration = 4; -} - -// Config update result -message UpdateAuthConfigResponse { - string message = 1; - bool requires_first_user = 2; // If enabling auth with no users -} - // Empty current user request message GetCurrentUserRequest {} -// Authenticated user info +// Authenticated user with permissions message GetCurrentUserResponse { User user = 1; + repeated Permission permissions = 2; } -// Password change credentials +// Password change message ChangePasswordRequest { string old_password = 1; string new_password = 2; @@ -127,3 +93,43 @@ message ChangePasswordRequest { message ChangePasswordResponse { string message = 1; } + +// OIDC login URL request +message GetOIDCLoginURLRequest {} + +// OIDC login redirect URL +message GetOIDCLoginURLResponse { + string login_url = 1; +} + +// Empty auth config request +message GetAuthConfigRequest {} + +// Full auth configuration for admin display +message GetAuthConfigResponse { + bool local_auth_enabled = 1; + bool allow_registration = 2; + bool anonymous_access = 3; + int32 session_timeout = 4; + bool oidc_enabled = 5; + // OIDC display details (non-secret, only populated when OIDC is enabled) + optional string oidc_issuer_uri = 6; + optional string oidc_client_id = 7; + optional string oidc_redirect_url = 8; + repeated string oidc_scopes = 9; + optional string oidc_role_claim = 10; + bool first_user_setup = 11; +} + +// Update mutable auth settings (all fields optional, only provided fields are updated) +message UpdateAuthSettingsRequest { + optional bool local_auth_enabled = 1; + optional bool allow_registration = 2; + optional bool anonymous_access = 3; + optional int32 session_timeout = 4; +} + +// Returns the updated auth configuration +message UpdateAuthSettingsResponse { + GetAuthConfigResponse config = 1; +} diff --git a/proto/discopanel/v1/common.proto b/proto/discopanel/v1/common.proto index 0aa2d98..cdbe417 100644 --- a/proto/discopanel/v1/common.proto +++ b/proto/discopanel/v1/common.proto @@ -6,14 +6,6 @@ import "google/protobuf/timestamp.proto"; option go_package = "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1;discopanelv1"; -// User permission levels -enum UserRole { - USER_ROLE_UNSPECIFIED = 0; - USER_ROLE_VIEWER = 1; - USER_ROLE_EDITOR = 2; - USER_ROLE_ADMIN = 3; -} - // Container runtime state enum ServerStatus { SERVER_STATUS_UNSPECIFIED = 0; @@ -54,11 +46,12 @@ message User { string id = 1; string username = 2; optional string email = 3; - UserRole role = 4; + string auth_provider = 4; bool is_active = 5; - optional string recovery_key = 6; + repeated string roles = 6; google.protobuf.Timestamp created_at = 7; google.protobuf.Timestamp updated_at = 8; + optional google.protobuf.Timestamp last_login = 9; } // Minecraft server instance @@ -170,13 +163,21 @@ message ProxyConfig { google.protobuf.Timestamp updated_at = 5; } -// Authentication system settings -message AuthConfig { +// Permission entry for RBAC +message Permission { + string resource = 1; + string action = 2; + string object_id = 3; +} + +// Role definition +message Role { string id = 1; - bool enabled = 2; - int32 session_timeout = 3; - bool require_email_verify = 4; - bool allow_registration = 5; - google.protobuf.Timestamp created_at = 6; - google.protobuf.Timestamp updated_at = 7; + string name = 2; + string description = 3; + bool is_system = 4; + bool is_default = 5; + repeated Permission permissions = 6; + google.protobuf.Timestamp created_at = 7; + google.protobuf.Timestamp updated_at = 8; } diff --git a/proto/discopanel/v1/role.proto b/proto/discopanel/v1/role.proto new file mode 100644 index 0000000..b29ff3d --- /dev/null +++ b/proto/discopanel/v1/role.proto @@ -0,0 +1,159 @@ +syntax = "proto3"; + +package discopanel.v1; + +import "discopanel/v1/common.proto"; + +option go_package = "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1;discopanelv1"; + +// Role and permission management service +service RoleService { + // List all roles + rpc ListRoles(ListRolesRequest) returns (ListRolesResponse); + // Get a single role + rpc GetRole(GetRoleRequest) returns (GetRoleResponse); + // Create a new custom role + rpc CreateRole(CreateRoleRequest) returns (CreateRoleResponse); + // Update a role + rpc UpdateRole(UpdateRoleRequest) returns (UpdateRoleResponse); + // Delete a custom role + rpc DeleteRole(DeleteRoleRequest) returns (DeleteRoleResponse); + // Get permission matrix for all roles + rpc GetPermissionMatrix(GetPermissionMatrixRequest) returns (GetPermissionMatrixResponse); + // Set permissions for a role + rpc UpdatePermissions(UpdatePermissionsRequest) returns (UpdatePermissionsResponse); + // Assign a role to a user + rpc AssignRole(AssignRoleRequest) returns (AssignRoleResponse); + // Unassign a role from a user + rpc UnassignRole(UnassignRoleRequest) returns (UnassignRoleResponse); + // Get roles for a user + rpc GetUserRoles(GetUserRolesRequest) returns (GetUserRolesResponse); +} + +// Empty list request +message ListRolesRequest {} + +// All roles +message ListRolesResponse { + repeated Role roles = 1; +} + +// Get role by ID +message GetRoleRequest { + string id = 1; +} + +// Single role +message GetRoleResponse { + Role role = 1; +} + +// New role details +message CreateRoleRequest { + string name = 1; + string description = 2; + bool is_default = 3; + repeated Permission permissions = 4; +} + +// Created role +message CreateRoleResponse { + Role role = 1; +} + +// Role fields to update +message UpdateRoleRequest { + string id = 1; + optional string name = 2; + optional string description = 3; + optional bool is_default = 4; +} + +// Updated role +message UpdateRoleResponse { + Role role = 1; +} + +// Role to delete +message DeleteRoleRequest { + string id = 1; +} + +// Deletion confirmation +message DeleteRoleResponse { + string message = 1; +} + +// Permission matrix request +message GetPermissionMatrixRequest { + bool include_objects = 1; +} + +// An object that can be scoped in permissions (e.g. a specific server) +message ScopeableObject { + string id = 1; + string name = 2; + string resource = 3; + string scope_source = 4; // Entity type providing scope (e.g., "servers" when files scoped by server) +} + +// Valid actions per resource, derived from procedure mappings +message ResourceActions { + string resource = 1; + repeated string actions = 2; +} + +// Permission matrix response +message GetPermissionMatrixResponse { + repeated ResourceActions resource_actions = 1; + map role_permissions = 2; + repeated ScopeableObject available_objects = 3; +} + +// Permissions for a single role +message RolePermissions { + repeated Permission permissions = 1; +} + +// Update permissions for a role +message UpdatePermissionsRequest { + string role_name = 1; + repeated Permission permissions = 2; +} + +// Update confirmation +message UpdatePermissionsResponse { + string message = 1; +} + +// Assign role to user +message AssignRoleRequest { + string user_id = 1; + string role_name = 2; +} + +// Assignment confirmation +message AssignRoleResponse { + string message = 1; +} + +// Unassign role from user +message UnassignRoleRequest { + string user_id = 1; + string role_name = 2; +} + +// Unassignment confirmation +message UnassignRoleResponse { + string message = 1; +} + +// Get user roles request +message GetUserRolesRequest { + string user_id = 1; +} + +// User roles response +message GetUserRolesResponse { + repeated string roles = 1; +} diff --git a/proto/discopanel/v1/user.proto b/proto/discopanel/v1/user.proto index 5223a34..ce94f39 100644 --- a/proto/discopanel/v1/user.proto +++ b/proto/discopanel/v1/user.proto @@ -10,6 +10,8 @@ option go_package = "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1;dis service UserService { // Get all system users rpc ListUsers(ListUsersRequest) returns (ListUsersResponse); + // Get a single user + rpc GetUser(GetUserRequest) returns (GetUserResponse); // Create new user account rpc CreateUser(CreateUserRequest) returns (CreateUserResponse); // Modify user account @@ -26,12 +28,22 @@ message ListUsersResponse { repeated User users = 1; } +// Get user by ID +message GetUserRequest { + string id = 1; +} + +// Single user +message GetUserResponse { + User user = 1; +} + // New user details message CreateUserRequest { string username = 1; string email = 2; string password = 3; - UserRole role = 4; + repeated string roles = 4; } // Created user @@ -43,8 +55,8 @@ message CreateUserResponse { message UpdateUserRequest { string id = 1; optional string email = 2; - optional UserRole role = 3; - optional bool is_active = 4; + optional bool is_active = 3; + repeated string roles = 4; } // Updated user diff --git a/web/discopanel/src/lib/api/rpc-client.ts b/web/discopanel/src/lib/api/rpc-client.ts index 6e10042..da1f265 100644 --- a/web/discopanel/src/lib/api/rpc-client.ts +++ b/web/discopanel/src/lib/api/rpc-client.ts @@ -17,6 +17,7 @@ import { SupportService } from '$lib/proto/discopanel/v1/support_pb'; import { TaskService } from '$lib/proto/discopanel/v1/task_pb'; import { UploadService } from '$lib/proto/discopanel/v1/upload_pb'; import { UserService } from '$lib/proto/discopanel/v1/user_pb'; +import { RoleService } from '$lib/proto/discopanel/v1/role_pb'; import { ModuleService } from '$lib/proto/discopanel/v1/module_pb'; // Header to mark requests as silent / no loader @@ -81,6 +82,7 @@ export class RpcClient { public readonly task: Client; public readonly upload: Client; public readonly user: Client; + public readonly role: Client; public readonly module: Client; constructor() { @@ -96,6 +98,7 @@ export class RpcClient { this.task = createClient(TaskService, transport); this.upload = createClient(UploadService, transport); this.user = createClient(UserService, transport); + this.role = createClient(RoleService, transport); this.module = createClient(ModuleService, transport); } } diff --git a/web/discopanel/src/lib/components/auth-settings.svelte b/web/discopanel/src/lib/components/auth-settings.svelte index a848c97..0a1af44 100644 --- a/web/discopanel/src/lib/components/auth-settings.svelte +++ b/web/discopanel/src/lib/components/auth-settings.svelte @@ -1,340 +1,388 @@ - -
- -
-
- -
-
- Authentication Settings - - Configure user authentication and access control - -
-
-
- - {#if loading} -
-
- -
Loading authentication settings...
+{#if loading} +
+ +
+{:else if config} +
+ + +
+ +
+
+ +
+
+ Authentication Settings + + Manage login methods and access controls + +
-
- {:else} -
- -
+ + + +
- -

- Require users to log in to access DiscoPanel -

+ +

Username and password login

- authConfig.enabled = checked} - disabled={saving} - /> -
- - {#if authConfig.enabled} - -
- - { localAuthEnabled = v; if (!v) allowRegistration = false; }} disabled={saving} - class="max-w-xs" /> -

- How long users stay logged in (default: 24 hours) -

+ {:else} + + {#if localAuthEnabled} + Enabled + {:else} + Disabled + {/if} + + {/if} +
+ + +
+
+ +

Allow new users to self-register

- - -
-
- -

- Allow new users to create accounts (they'll have viewer role by default) -

-
+ {#if canEdit} authConfig.allowRegistration = checked} + checked={allowRegistration} + onCheckedChange={(v) => { allowRegistration = v; }} + disabled={saving || !localAuthEnabled} + /> + {:else} + + {#if allowRegistration} + Allowed + {:else} + Disabled + {/if} + + {/if} +
+ + +
+
+ +

Limited unauthenticated browsing

+
+ {#if canEdit} + { anonymousAccess = v; }} disabled={saving} /> + {:else} + + {#if anonymousAccess} + Enabled + {:else} + Disabled + {/if} + + {/if} +
+ + +
+
+ +

How long sessions remain valid

- - {#if userCount > 0} - -
-
- - User Statistics -
-

- Total users: {userCount} -

+ {#if canEdit} +
+ + hours
- {/if} - - - - - - A recovery key will be generated and saved to the server's data directory when authentication is enabled. - Keep this key secure - it can be used to reset any user's password. - - - {/if} -
- -
- -
- {/if} - - +
- - showFirstUserDialog = open}> - - - Create Admin Account - - Create the first admin account to enable authentication. This account will have full system access. - - - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - - + {/if} + + + + + +
+ +
+
+ +
+
+
+ OIDC / Single Sign-On + + External identity provider integration + +
+ {#if config.oidcEnabled} + + Connected + + {/if} +
+
+
+ + {#if config.oidcEnabled} + +
+ {#if config.oidcIssuerUri} +
+ +

{config.oidcIssuerUri}

+
+ {/if} + + {#if config.oidcClientId} +
+ +

{config.oidcClientId}

+
+ {/if} + + {#if config.oidcRedirectUrl} +
+ +

{config.oidcRedirectUrl}

+
+ {/if} + + {#if config.oidcScopes && config.oidcScopes.length > 0} +
+ +
+ {#each config.oidcScopes as scope} + {scope} + {/each} +
+
+ {/if} + + {#if config.oidcRoleClaim} +
+ +

{config.oidcRoleClaim}

+
+ {/if} +
{:else} - Create Admin & Enable Auth + +
+ +

+ OIDC allows users to sign in with external identity providers like Keycloak, Authelia, Google, or any OpenID Connect compatible service. +

+

+ OIDC must be configured outside the UI using one of the methods below. +

+
+ + + + + + + config.yaml + + + +
auth:
+  oidc:
+    enabled: true
+    issuer_uri: "https://your-provider/.well-known/openid-configuration"
+    client_id: "discopanel"
+    client_secret: "your-secret"
+    redirect_url: "https://your-domain/api/v1/auth/oidc/callback"
+    scopes: ["openid", "profile", "email", "groups"]
+    role_claim: "groups"
+
+
+ + + + + + Docker Compose + + + +
services:
+  discopanel:
+    environment:
+      DISCOPANEL_AUTH_OIDC_ENABLED: "true"
+      DISCOPANEL_AUTH_OIDC_ISSUER_URI: "https://..."
+      DISCOPANEL_AUTH_OIDC_CLIENT_ID: "discopanel"
+      DISCOPANEL_AUTH_OIDC_CLIENT_SECRET: "your-secret"
+      DISCOPANEL_AUTH_OIDC_REDIRECT_URL: "https://..."
+      DISCOPANEL_AUTH_OIDC_SCOPES: "openid,profile,email,groups"
+      DISCOPANEL_AUTH_OIDC_ROLE_CLAIM: "groups"
+
+
+ + + + + + Environment Variables + + + +
+

DISCOPANEL_AUTH_OIDC_ENABLED

+

DISCOPANEL_AUTH_OIDC_ISSUER_URI

+

DISCOPANEL_AUTH_OIDC_CLIENT_ID

+

DISCOPANEL_AUTH_OIDC_CLIENT_SECRET

+

DISCOPANEL_AUTH_OIDC_REDIRECT_URL

+

DISCOPANEL_AUTH_OIDC_SCOPES

+

DISCOPANEL_AUTH_OIDC_ROLE_CLAIM

+
+
+
+
+ +

+ Provider examples are available in the oidc directory of the project repo. +

{/if} - -
-
-
\ No newline at end of file + + +
+{/if} diff --git a/web/discopanel/src/lib/components/role-settings.svelte b/web/discopanel/src/lib/components/role-settings.svelte new file mode 100644 index 0000000..772dff8 --- /dev/null +++ b/web/discopanel/src/lib/components/role-settings.svelte @@ -0,0 +1,701 @@ + + +
+
+

Manage roles and their permissions

+ {#if canCreate} + + {/if} +
+ + + + {#if loading} +
+
+ +
Loading roles...
+
+
+ {:else} + + + + Name + Description + Type + Default + Permissions + {#if canDelete} + Actions + {/if} + + + + {#each roles as role} + + {role.name} + {role.description || '-'} + + {#if role.isSystem} + System + {:else} + Custom + {/if} + + + {#if role.isDefault} + + {:else} + + {/if} + + + {#if hasFullAccess(role.name)} + Full Access + {:else if canUpdate} + + {:else} + {role.permissions?.length || 0} permissions + {/if} + + {#if canDelete} + + {#if !role.isSystem} + + {/if} + + {/if} + + {/each} + +
+ {/if} +
+
+
+ + + showCreateDialog = open}> + + + Create New Role + + Create a custom role with specific permissions. + + + +
+
+ + +
+
+ + +
+
+ newRoleForm.isDefault = checked} + /> + +
+
+ + + + + +
+
+ + + showPermissionsDialog = open}> + +
+ +
+ +
+
+
+ +
+
+

{editingRole?.name}

+

+ {editingRole?.isSystem ? 'System role' : 'Custom role'} +

+
+
+
+ + + + + +
+
+

Permission Model

+

+ Global permissions apply to all objects. Scoped permissions target specific objects like individual servers. +

+
+
+
+ + +
+ +
+
+

+ {#if activeSection === 'global'}Global Permissions + {:else}Scoped Permissions + {/if} +

+

+ {#if activeSection === 'global'}Toggle access to resources and their actions + {:else}Grant access to specific objects instead of all objects of a type + {/if} +

+
+ +
+ + +
+ {#if activeSection === 'global'} + +
+ + + + Resource + {#each allActions as action} + + {action} + + {/each} + + + + {#each resourceActions as ra} + {@const count = getResourcePermCount(ra.resource)} + {@const total = ra.actions.length} + + +
+ toggleResourceAll(ra.resource)} + /> + {formatResourceName(ra.resource)} + {#if count > 0} + {count}/{total} + {/if} +
+
+ {#each allActions as action} + {@const key = `${ra.resource}:${action}`} + {@const hasAction = ra.actions.includes(action)} + {@const checked = hasAction && (editingPermissions[key] || false)} + + {#if hasAction} +
+ togglePermission(key)} + /> +
+ {:else} + + {/if} +
+ {/each} +
+ {/each} +
+
+
+ + {:else} + + {#if scopeableResources.length === 0} +
+ +

No scopeable resources

+

+ No objects exist yet to scope permissions to. Create resources like servers first. +

+
+ {:else} +
+ +
+ {#each scopeableResources as res} + {@const objectCount = availableObjects.filter(o => o.resource === res).length} + {@const source = scopeSourceMap[res]} + {@const isForeign = source && source !== res} + + {/each} +
+ + {#if scopedResource} + {@const activeSource = scopeSourceMap[scopedResource]} + {@const activeForeign = activeSource && activeSource !== scopedResource} + {#if filteredObjects.length === 0} +
+ +

No {formatResourceName(activeForeign ? activeSource : scopedResource)}

+

+ {#if activeForeign} + No {formatResourceName(activeSource)} exist to scope {formatResourceName(scopedResource)} permissions. + {:else} + No {formatResourceName(scopedResource)} exist yet to scope permissions to. + {/if} +

+
+ {:else} +
+ + + + + {formatResourceName(activeForeign ? activeSource : scopedResource)} + {#if activeForeign} +
scoping {formatResourceName(scopedResource)}
+ {/if} +
+ {#each scopedResourceActions as action} + {@const coveredByGlobal = editingPermissions[`${scopedResource}:${action}`] || false} + + {action} + {#if coveredByGlobal} +
(global)
+ {/if} +
+ {/each} +
+
+ + {#each filteredObjects as obj} + + + {obj.name} + + {#each scopedResourceActions as action} + {@const coveredByGlobal = editingPermissions[`${scopedResource}:${action}`] || false} + {@const checked = isScopedChecked(scopedResource, action, obj.id)} + +
+ toggleScopedPermission(scopedResource, action, obj.id, obj.name)} + /> +
+
+ {/each} +
+ {/each} +
+
+
+ {/if} + {:else} +
+ +

Select a resource type above to manage scoped permissions

+
+ {/if} +
+ {/if} + {/if} +
+ + +
+ + +
+
+
+
+
diff --git a/web/discopanel/src/routes/users/+page.svelte b/web/discopanel/src/lib/components/user-settings.svelte similarity index 60% rename from web/discopanel/src/routes/users/+page.svelte rename to web/discopanel/src/lib/components/user-settings.svelte index ae4193b..414ab03 100644 --- a/web/discopanel/src/routes/users/+page.svelte +++ b/web/discopanel/src/lib/components/user-settings.svelte @@ -1,26 +1,27 @@ -
+
-
-
- -
-
-

User Management

-

Manage user accounts and permissions

-
-
- - +

Manage user accounts and role assignments

+ {#if canCreate} + + {/if}
- + {#if loading} @@ -218,11 +194,13 @@ Username Email - Role + Provider + Roles Status Created - Last Active - Actions + {#if canUpdate || canDelete} + Actions + {/if} @@ -231,12 +209,19 @@ {user.username} {user.email || '-'} - {@const badge = getRoleBadge(user.role)} - {@const Icon = badge.icon} - - - {getRoleDisplayName(user.role)} - + {user.authProvider || 'local'} + + +
+ {#each user.roles || [] as role} + + {role} + + {/each} + {#if !user.roles?.length} + None + {/if} +
{#if user.isActive} @@ -248,28 +233,31 @@ {user.createdAt ? formatDate(new Date(Number(user.createdAt.seconds) * 1000).toISOString()) : 'Unknown'} - - {user.updatedAt ? formatDate(new Date(Number(user.updatedAt.seconds) * 1000).toISOString()) : 'Never'} - - -
- - -
-
+ {#if canUpdate || canDelete} + +
+ {#if canUpdate} + + {/if} + {#if canDelete} + + {/if} +
+
+ {/if} {/each}
@@ -285,10 +273,10 @@ Create New User - Add a new user to the system with specific permissions. + Add a new user to the system with specific roles. - +
@@ -320,27 +308,21 @@ />
- - + +
+ {#each availableRoles as role} + + {/each} +
- + + {/each} +
{/if} - + - \ No newline at end of file + diff --git a/web/discopanel/src/lib/stores/auth.ts b/web/discopanel/src/lib/stores/auth.ts index 9b00be6..ddf94c9 100644 --- a/web/discopanel/src/lib/stores/auth.ts +++ b/web/discopanel/src/lib/stores/auth.ts @@ -3,34 +3,53 @@ import { goto } from '$app/navigation'; import { browser } from '$app/environment'; import { create } from '@bufbuild/protobuf'; import { rpcClient } from '$lib/api/rpc-client'; -import type { User } from '$lib/proto/discopanel/v1/common_pb'; -import { UserRole } from '$lib/proto/discopanel/v1/common_pb'; +import type { User, Permission } from '$lib/proto/discopanel/v1/common_pb'; import { LoginRequestSchema, RegisterRequestSchema, - ResetPasswordRequestSchema, ChangePasswordRequestSchema } from '$lib/proto/discopanel/v1/auth_pb'; interface AuthState { user: User | null; token: string | null; + permissions: Permission[]; isAuthenticated: boolean; isLoading: boolean; - authEnabled: boolean; + localAuthEnabled: boolean; + oidcEnabled: boolean; firstUserSetup: boolean; allowRegistration: boolean; + anonymousAccessEnabled: boolean; +} + +/** Check if a permission list grants access for the given resource/action/objectId. */ +function checkPermission( + permissions: Permission[], + resource: string, + action: string, + objectId?: string +): boolean { + return permissions.some( + (p) => + (p.resource === '*' || p.resource === resource) && + (p.action === '*' || p.action === action) && + (p.objectId === '*' || !objectId || p.objectId === objectId) + ); } function createAuthStore() { const { subscribe, set, update } = writable({ user: null, token: null, + permissions: [], isAuthenticated: false, isLoading: true, - authEnabled: false, + localAuthEnabled: true, + oidcEnabled: false, firstUserSetup: false, allowRegistration: false, + anonymousAccessEnabled: false, }); // Load token from localStorage on init @@ -43,16 +62,20 @@ function createAuthStore() { return { subscribe, - + async checkAuthStatus() { try { const response = await rpcClient.auth.getAuthStatus({}); + const authEnabled = response.localAuthEnabled || response.oidcEnabled; + update(state => ({ ...state, - authEnabled: response.enabled, + localAuthEnabled: response.localAuthEnabled, + oidcEnabled: response.oidcEnabled, firstUserSetup: response.firstUserSetup, allowRegistration: response.allowRegistration, + anonymousAccessEnabled: response.anonymousAccessEnabled, })); // If auth is enabled and we have a token, validate it @@ -62,14 +85,33 @@ function createAuthStore() { return state; }); - if (response.enabled && currentToken) { + if (!authEnabled) { + // Auth is disabled - backend grants full admin access, fetch permissions + await rpcClient.auth.getCurrentUser({}).then(r => update(state => ({ + ...state, + user: r.user || null, + permissions: r.permissions ?? [], + isLoading: false, + }))).catch(() => update(state => ({ ...state, isLoading: false }))); + } else if (currentToken) { await this.validateSession(); + } else if (response.anonymousAccessEnabled) { + try { + const r = await rpcClient.auth.getCurrentUser({}); + update(state => ({ + ...state, + permissions: r.permissions ?? [], + isLoading: false, + })); + } catch { + update(state => ({ ...state, isLoading: false })); + } } else { update(state => ({ ...state, isLoading: false })); } return { - enabled: response.enabled, + enabled: authEnabled, firstUserSetup: response.firstUserSetup, allowRegistration: response.allowRegistration }; @@ -79,7 +121,7 @@ function createAuthStore() { return { enabled: false, firstUserSetup: false, allowRegistration: false }; } }, - + async login(username: string, password: string) { try { const request = create(LoginRequestSchema, { username, password }); @@ -98,28 +140,18 @@ function createAuthStore() { isLoading: false, })); + // Fetch permissions after login + await this.validateSession(); + return response; } catch (error) { update(state => ({ ...state, isLoading: false })); throw error; } }, - - async logout() { - let currentState: AuthState = { - user: null, - token: null, - isAuthenticated: false, - isLoading: false, - authEnabled: false, - firstUserSetup: false, - allowRegistration: false, - }; - update(state => { - currentState = state; - return state; - }); + async logout() { + let currentState: AuthState = get({ subscribe }); try { if (currentState.token) { @@ -138,29 +170,35 @@ function createAuthStore() { set({ user: null, token: null, + permissions: [], isAuthenticated: false, isLoading: false, - authEnabled: currentState.authEnabled, + localAuthEnabled: currentState.localAuthEnabled, + oidcEnabled: currentState.oidcEnabled, firstUserSetup: currentState.firstUserSetup, allowRegistration: currentState.allowRegistration, + anonymousAccessEnabled: currentState.anonymousAccessEnabled, }); // Redirect to login goto('/login'); }, - + async register(username: string, email: string, password: string) { - try { - const request = create(RegisterRequestSchema, { username, email, password }); - await rpcClient.auth.register(request); + const request = create(RegisterRequestSchema, { username, email, password }); + await rpcClient.auth.register(request); - // After successful registration, log them in - return await this.login(username, password); - } catch (error) { - throw error; + // After successful registration, log them in + return await this.login(username, password); + }, + + setToken(token: string) { + if (browser) { + localStorage.setItem('auth_token', token); } + update(state => ({ ...state, token })); }, - + async changePassword(oldPassword: string, newPassword: string) { try { const request = create(ChangePasswordRequestSchema, { @@ -173,21 +211,7 @@ function createAuthStore() { throw new Error(error.message || 'Failed to change password'); } }, - - async resetPassword(username: string, recoveryKey: string, newPassword: string) { - try { - const request = create(ResetPasswordRequestSchema, { - username, - recoveryKey, - newPassword - }); - const response = await rpcClient.auth.resetPassword(request); - return response; - } catch (error: any) { - throw new Error(error.message || 'Failed to reset password'); - } - }, - + async validateSession() { let currentToken: string | null = null; update(state => { @@ -207,6 +231,7 @@ function createAuthStore() { update(state => ({ ...state, user: response.user || null, + permissions: response.permissions || [], isAuthenticated: true, isLoading: false, })); @@ -220,6 +245,7 @@ function createAuthStore() { ...state, user: null, token: null, + permissions: [], isAuthenticated: false, isLoading: false, })); @@ -235,26 +261,32 @@ function createAuthStore() { ...state, user: null, token: null, + permissions: [], isAuthenticated: false, isLoading: false, })); return false; } }, - + getHeaders() { - let currentToken: string | null = null; - update(state => { - currentToken = state.token; - return state; - }); - + const state = get({ subscribe }); const headers: HeadersInit = {}; - if (currentToken) { - headers['Authorization'] = `Bearer ${currentToken}`; + if (state.token) { + headers['Authorization'] = `Bearer ${state.token}`; } return headers; }, + + hasRole(role: string): boolean { + const state = get({ subscribe }); + return state.user?.roles?.includes(role) ?? false; + }, + + hasPermission(resource: string, action: string, objectId?: string): boolean { + const state = get({ subscribe }); + return checkPermission(state.permissions, resource, action, objectId); + }, }; } @@ -263,9 +295,29 @@ export const authStore = createAuthStore(); // Derived stores for convenience export const isAuthenticated = derived(authStore, $auth => $auth.isAuthenticated); export const currentUser = derived(authStore, $auth => $auth.user); -export const isAdmin = derived(authStore, $auth => $auth.user?.role === UserRole.ADMIN); -export const isEditor = derived(authStore, $auth => $auth.user?.role === UserRole.EDITOR || $auth.user?.role === UserRole.ADMIN); -export const authEnabled = derived(authStore, $auth => $auth.authEnabled); +export const userPermissions = derived(authStore, $auth => $auth.permissions); +export const authEnabled = derived(authStore, $auth => $auth.localAuthEnabled || $auth.oidcEnabled); + +// Permission-based derived stores +export const canReadUsers = derived(authStore, $auth => checkPermission($auth.permissions, 'users', 'read')); +export const canCreateUsers = derived(authStore, $auth => checkPermission($auth.permissions, 'users', 'create')); +export const canUpdateUsers = derived(authStore, $auth => checkPermission($auth.permissions, 'users', 'update')); +export const canDeleteUsers = derived(authStore, $auth => checkPermission($auth.permissions, 'users', 'delete')); +export const canReadRoles = derived(authStore, $auth => checkPermission($auth.permissions, 'roles', 'read')); +export const canCreateRoles = derived(authStore, $auth => checkPermission($auth.permissions, 'roles', 'create')); +export const canUpdateRoles = derived(authStore, $auth => checkPermission($auth.permissions, 'roles', 'update')); +export const canDeleteRoles = derived(authStore, $auth => checkPermission($auth.permissions, 'roles', 'delete')); +export const canReadSettings = derived(authStore, $auth => checkPermission($auth.permissions, 'settings', 'read')); +export const canUpdateSettings = derived(authStore, $auth => checkPermission($auth.permissions, 'settings', 'update')); +export const canReadServers = derived(authStore, $auth => checkPermission($auth.permissions, 'servers', 'read')); +export const canCreateServers = derived(authStore, $auth => checkPermission($auth.permissions, 'servers', 'create')); +export const canReadModpacks = derived(authStore, $auth => checkPermission($auth.permissions, 'modpacks', 'read')); + +// Check if user has any settings-adjacent permission (for sidebar visibility) +export const canAccessSettings = derived(authStore, $auth => + checkPermission($auth.permissions, 'settings', 'read') || + checkPermission($auth.permissions, 'users', 'read') || + checkPermission($auth.permissions, 'roles', 'read') +); -// Make auth store values accessible as a readable store -export const $authStore = derived(authStore, $auth => $auth); \ No newline at end of file +export { checkPermission }; diff --git a/web/discopanel/src/lib/stores/servers.ts b/web/discopanel/src/lib/stores/servers.ts index 3ca4ad5..f89b351 100644 --- a/web/discopanel/src/lib/stores/servers.ts +++ b/web/discopanel/src/lib/stores/servers.ts @@ -10,6 +10,7 @@ function createServersStore() { return { subscribe, + set, fetchServers: async (skipLoading = false) => { try { const request = create(ListServersRequestSchema, { fullStats: false }); @@ -26,9 +27,9 @@ function createServersStore() { update(servers => { const index = servers.findIndex(s => s.id === server.id); if (index !== -1) { - servers[index] = server; + return [...servers.slice(0, index), server, ...servers.slice(index + 1)]; } - return servers; + return [...servers, server]; }); }, removeServer: (id: string) => { diff --git a/web/discopanel/src/lib/utils/role-colors.ts b/web/discopanel/src/lib/utils/role-colors.ts new file mode 100644 index 0000000..bdaa0bd --- /dev/null +++ b/web/discopanel/src/lib/utils/role-colors.ts @@ -0,0 +1,16 @@ +/** Badge variant type matching the UI component's expected variants. */ +type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline'; + +const BADGE_VARIANTS: BadgeVariant[] = ['default', 'secondary', 'destructive', 'outline']; + +/** + * Deterministically maps a role name to a badge variant using a simple hash. + * Works for any dynamic role name without hardcoding specific names. + */ +export function getRoleBadgeVariant(roleName: string): BadgeVariant { + let hash = 0; + for (let i = 0; i < roleName.length; i++) { + hash = ((hash << 5) - hash + roleName.charCodeAt(i)) | 0; + } + return BADGE_VARIANTS[Math.abs(hash) % BADGE_VARIANTS.length]; +} diff --git a/web/discopanel/src/routes/+layout.svelte b/web/discopanel/src/routes/+layout.svelte index d91ea4c..ca07918 100644 --- a/web/discopanel/src/routes/+layout.svelte +++ b/web/discopanel/src/routes/+layout.svelte @@ -30,31 +30,36 @@ DropdownMenuSeparator, DropdownMenuTrigger } from '$lib/components/ui/dropdown-menu'; + import { get } from 'svelte/store'; import { serversStore, runningServers } from '$lib/stores/servers'; - import { authStore, currentUser, isAdmin } from '$lib/stores/auth'; + import { authStore, currentUser, canAccessSettings, authEnabled } from '$lib/stores/auth'; import { onMount } from 'svelte'; import { Toaster } from '$lib/components/ui/sonner'; import GlobalLoading from '$lib/components/global-loading.svelte'; - import { Server, Home, Settings, Package, User, Users, LogOut, FileText, Sun, Moon } from '@lucide/svelte'; + import { Server, Home, Settings, Package, User, LogOut, LogIn, FileText, Sun, Moon } from '@lucide/svelte'; import { toggleMode, mode } from 'mode-watcher'; - import { ServerStatus, UserRole } from '$lib/proto/discopanel/v1/common_pb'; - import { getStringForEnum } from '$lib/utils'; + import { ServerStatus } from '$lib/proto/discopanel/v1/common_pb'; let { children } = $props(); let servers = $derived($serversStore); let runningCount = $derived($runningServers.length); let user = $derived($currentUser); - let isUserAdmin = $derived($isAdmin); + let showSettingsNav = $derived($canAccessSettings); let loading = $state(true); - let isAuthEnabled = $derived($authStore.authEnabled); + let isAuthEnabled = $derived($authEnabled); function getUserInitials(user: any) { if (!user) return ''; return user.username.slice(0, 2).toUpperCase(); } + function getDisplayRole(user: any): string { + if (!user?.roles?.length) return 'No roles'; + return user.roles[0]; + } + async function handleLogout() { await authStore.logout(); } @@ -72,8 +77,12 @@ } const isValid = await authStore.validateSession(); if (!isValid) { - goto('/login'); - return; + // If anonymous access is enabled, allow browsing without login + const state = get(authStore); + if (!state.anonymousAccessEnabled) { + goto('/login'); + return; + } } } }).then(() => { @@ -173,28 +182,18 @@ {/snippet} - {#if isUserAdmin} + {#if showSettingsNav} - + {#snippet child({ props })} - - - Users + + + Settings {/snippet} {/if} - - - {#snippet child({ props })} - - - Settings - - {/snippet} - - {#snippet child({ props })} @@ -259,7 +258,7 @@

{user.username}

-

{getStringForEnum(UserRole, user.role)}

+

{getDisplayRole(user)}

{/snippet} @@ -272,7 +271,9 @@ {#if user.email}

{user.email}

{/if} -

Role: {user.role}

+

+ {user.roles?.length ? user.roles.join(', ') : 'No roles'} +

@@ -280,20 +281,21 @@ Profile - {#if isUserAdmin} - - goto('/users')}> - - Manage Users - - {/if} Log out - + +
+ {:else if isAuthEnabled && $authStore.anonymousAccessEnabled && !user} + +
+
{/if} diff --git a/web/discopanel/src/routes/+page.svelte b/web/discopanel/src/routes/+page.svelte index 6e09abe..0760389 100644 --- a/web/discopanel/src/routes/+page.svelte +++ b/web/discopanel/src/routes/+page.svelte @@ -16,18 +16,19 @@ } from '@lucide/svelte'; import { ServerStatus, type Server as ServerType } from '$lib/proto/discopanel/v1/common_pb'; import { rpcClient } from '$lib/api/rpc-client'; + import { serversStore } from '$lib/stores/servers'; - // Dashboard data - not from the polling store + // Dashboard data let dashboardServers: ServerType[] = $state([]); let isLoading = $state(true); let isRefreshing = $state(false); let currentTime = $state(new Date()); - // Load dashboard data with full stats async function loadDashboardData() { try { const response = await rpcClient.server.listServers({ fullStats: true }); dashboardServers = response.servers; + serversStore.set(response.servers); } catch (error) { console.error('Failed to load dashboard data:', error); } diff --git a/web/discopanel/src/routes/login/+page.svelte b/web/discopanel/src/routes/login/+page.svelte index af7b134..5fde465 100644 --- a/web/discopanel/src/routes/login/+page.svelte +++ b/web/discopanel/src/routes/login/+page.svelte @@ -5,18 +5,17 @@ import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; - import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '$lib/components/ui/card'; + import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Alert, AlertDescription } from '$lib/components/ui/alert'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs'; import { toast } from 'svelte-sonner'; import { Loader2, AlertCircle } from '@lucide/svelte'; - let mode = $state<'login' | 'register' | 'reset'>('login'); + let mode = $state<'login' | 'register'>('login'); let username = $state(''); let email = $state(''); let password = $state(''); let confirmPassword = $state(''); - let recoveryKey = $state(''); let loading = $state(false); let error = $state(''); let authStatus = $state({ @@ -24,40 +23,59 @@ firstUserSetup: false, allowRegistration: false }); + let oidcEnabled = $state(false); + let localAuthEnabled = $state(true); onMount(() => { + // Check for OIDC callback token + const urlParams = new URLSearchParams(window.location.search); + const token = urlParams.get('token'); + if (token) { + // Store token from OIDC callback in both localStorage and store state + authStore.setToken(token); + window.history.replaceState({}, '', '/login'); + authStore.validateSession().then(valid => { + if (valid) { + goto('/'); + } else { + error = 'Session validation failed. Please try again.'; + } + }); + return; + } + // If already authenticated, redirect to home if ($authStore.isAuthenticated) { goto('/'); + return; } // Check auth status authStore.checkAuthStatus().then(status => { authStatus = status; - + oidcEnabled = $authStore.oidcEnabled; + localAuthEnabled = $authStore.localAuthEnabled; + // If auth is disabled and not first user setup, redirect to home if (!status.enabled && !status.firstUserSetup) { goto('/'); return; } - + // If first user setup, show registration if (status.firstUserSetup) { mode = 'register'; } }); - - }); async function handleLogin() { error = ''; loading = true; - + try { await authStore.login(username, password); toast.success('Logged in successfully'); - // Small delay to ensure auth state is fully propagated before navigation setTimeout(() => { goto('/'); }, 100); @@ -69,25 +87,24 @@ async function handleRegister() { error = ''; - + if (password !== confirmPassword) { error = 'Passwords do not match'; return; } - + if (password.length < 8) { error = 'Password must be at least 8 characters'; return; } - + loading = true; - + try { await authStore.register(username, email, password); - toast.success(authStatus.firstUserSetup ? - 'Admin account created successfully' : + toast.success(authStatus.firstUserSetup ? + 'Admin account created successfully' : 'Account created successfully'); - // Small delay to ensure auth state is fully propagated before navigation setTimeout(() => { goto('/'); }, 100); @@ -97,36 +114,139 @@ } } - async function handleReset() { - error = ''; - loading = true; - + async function handleOIDCLogin() { try { - await authStore.resetPassword(username, recoveryKey, password); - toast.success('Password reset successfully'); - mode = 'login'; - password = ''; - recoveryKey = ''; + const response = await (await import('$lib/api/rpc-client')).rpcClient.auth.getOIDCLoginURL({}); + if (response.loginUrl) { + window.location.href = response.loginUrl; + } } catch (err: any) { - error = err.message || 'Password reset failed'; - } finally { - loading = false; + error = err.message || 'Failed to initiate SSO login'; } } function handleSubmit(e: Event) { e.preventDefault(); - + if (mode === 'login') { handleLogin(); - } else if (mode === 'register') { - handleRegister(); } else { - handleReset(); + handleRegister(); } } +{#snippet loginForm()} +
+ {#if localAuthEnabled} +
+
+ + +
+
+ + +
+ +
+ {/if} + + {#if oidcEnabled} + {#if localAuthEnabled} +
+
+ +
+
+ Or +
+
+ {/if} + + {/if} +
+{/snippet} + +{#snippet registerForm()} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+{/snippet} +
@@ -144,7 +264,7 @@ {/if} - + {#if error} @@ -153,159 +273,7 @@ {/if} - {#if !authStatus.firstUserSetup} - - - Login - {#if authStatus.allowRegistration} - Register - {/if} - Reset - - - -
-
- - -
-
- - -
- -
-
- - {#if authStatus.allowRegistration} - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
-
- {/if} - - -
-

- Enter your username, recovery key, and new password to reset your account. -

-
- - -
-
- - -
-
- - -
- -
-
-
- {:else} + {#if authStatus.firstUserSetup}
@@ -354,8 +322,11 @@ - This will be the admin account with full system access. - A recovery key will be generated and saved for password recovery. + {#if oidcEnabled} + A local admin account is required for initial setup, even with SSO enabled. This ensures you always have a fallback login to manage the system if your identity provider becomes unavailable. + {:else} + This will be the admin account with full system access. + {/if} + {:else if authStatus.allowRegistration && localAuthEnabled} + + + + Login + Register + + + + {@render loginForm()} + + + + {@render registerForm()} + + + {:else} + + {@render loginForm()} + {/if} + + {#if $authStore.anonymousAccessEnabled} +
+
+ +
+
+ Or +
+
+ {/if} -
\ No newline at end of file +
diff --git a/web/discopanel/src/routes/profile/+page.svelte b/web/discopanel/src/routes/profile/+page.svelte index cc23f43..a99c0ef 100644 --- a/web/discopanel/src/routes/profile/+page.svelte +++ b/web/discopanel/src/routes/profile/+page.svelte @@ -6,9 +6,9 @@ import { Label } from '$lib/components/ui/label'; import { Badge } from '$lib/components/ui/badge'; import { toast } from 'svelte-sonner'; - import { User, Key, Shield, Edit, Eye, Loader2 } from '@lucide/svelte'; - import { UserRole } from '$lib/proto/discopanel/v1/common_pb'; - + import { User, Key, Loader2, Mail, Calendar, Clock, Shield, Activity } from '@lucide/svelte'; + import { getRoleBadgeVariant } from '$lib/utils/role-colors'; + let user = $derived($currentUser); let passwordForm = $state({ oldPassword: '', @@ -16,23 +16,59 @@ confirmPassword: '' }); let saving = $state(false); - + + let initials = $derived( + user?.username + ? user.username + .split(/[\s_-]+/) + .slice(0, 2) + .map((w) => w[0]?.toUpperCase() ?? '') + .join('') + : '?' + ); + + let primaryRole = $derived(user?.roles?.[0] ?? 'user'); + + let memberSince = $derived( + user?.createdAt + ? new Date(Number(user.createdAt.seconds) * 1000).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + : 'Unknown' + ); + + let lastActive = $derived( + user?.lastLogin + ? new Date(Number(user.lastLogin.seconds) * 1000).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + : null + ); + + let providerLabel = $derived((user?.authProvider || 'local').toUpperCase()); + async function changePassword() { if (!passwordForm.oldPassword || !passwordForm.newPassword) { toast.error('Please fill in all fields'); return; } - + if (passwordForm.newPassword !== passwordForm.confirmPassword) { toast.error('New passwords do not match'); return; } - + if (passwordForm.newPassword.length < 8) { toast.error('New password must be at least 8 characters'); return; } - + saving = true; try { await authStore.changePassword(passwordForm.oldPassword, passwordForm.newPassword); @@ -48,163 +84,210 @@ saving = false; } } - - function getRoleIcon(role: UserRole) { - switch (role) { - case UserRole.ADMIN: - return Shield; - case UserRole.EDITOR: - return Edit; - case UserRole.VIEWER: - return Eye; - default: - return Eye; - } - } - - function getRoleBadgeVariant(role: UserRole) { - switch (role) { - case UserRole.ADMIN: - return 'destructive' as const; - case UserRole.EDITOR: - return 'secondary' as const; - case UserRole.VIEWER: - return 'outline' as const; - default: - return 'outline' as const; - } - } - - function getRoleDisplayName(role: UserRole): string { - switch (role) { - case UserRole.ADMIN: - return 'Admin'; - case UserRole.EDITOR: - return 'Editor'; - case UserRole.VIEWER: - return 'Viewer'; - default: - return 'Unknown'; - } - }
-
-
- -
-
-

Profile

-

Manage your account settings

-
-
- {#if user} + +
+
+ {initials} +
+
+
+

{user.username}

+ {primaryRole} +
+

Manage your account settings and security

+
+
+
- - - - - - Account Information - - Your account details and role + + +
+ +
+
+ +
+
+ Account Information + Your account details and roles +
+
- -
- -

{user.username}

+ + +
+ +
+

Username

+

{user.username}

+
+
+ + +
+ +
+
+

Auth Provider

+

{providerLabel}

+
+
+ {providerLabel}
- + + {#if user.email} -
- -

{user.email}

+
+ +
+

Email

+

{user.email}

+
{/if} - -
- -
- - {@const Icon = getRoleIcon(user.role)} - - {getRoleDisplayName(user.role)} - + + +
+ +
+

Roles

+
+ {#each user.roles || [] as role} + {role} + {/each} + {#if !user.roles?.length} + No roles assigned + {/if} +
- -
- -

{user.createdAt ? new Date(Number(user.createdAt.seconds) * 1000).toLocaleDateString() : 'Unknown'}

-
- {#if user.updatedAt} + +
+
- -

{new Date(Number(user.updatedAt.seconds) * 1000).toLocaleString()}

+

Member since

+

{memberSince}

+
+
+ + + {#if lastActive} +
+ +
+

Last active

+

{lastActive}

+
{/if} + + +
+ +
+

Account status

+

{user.isActive ? 'Active' : 'Inactive'}

+
+ + {user.isActive ? 'Active' : 'Inactive'} + +
- - - - - - - Change Password - - Update your account password + + + +
+ +
+
+ +
+
+ Security + Password and session management +
+
- -
{ e.preventDefault(); changePassword(); }} class="space-y-4"> -
- - + + +
+ +
+
+ Provider + {providerLabel} +
- -
- - +
+ + + {#if user.authProvider === 'local' || !user.authProvider} +
+ + { e.preventDefault(); changePassword(); }} class="space-y-3"> +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
- -
- - + {:else} +
+
+ +

+ Your account uses {providerLabel} authentication. Password changes are managed by your identity provider. +

+
- - - + {/if}
{/if} -
\ No newline at end of file +
diff --git a/web/discopanel/src/routes/settings/+page.svelte b/web/discopanel/src/routes/settings/+page.svelte index f0c02d8..a3c2a17 100644 --- a/web/discopanel/src/routes/settings/+page.svelte +++ b/web/discopanel/src/routes/settings/+page.svelte @@ -2,22 +2,43 @@ import { onMount } from 'svelte'; import ServerConfiguration from '$lib/components/server-configuration.svelte'; import ScrollToTop from '$lib/components/scroll-to-top.svelte'; + import UserSettings from '$lib/components/user-settings.svelte'; + import RoleSettings from '$lib/components/role-settings.svelte'; import { Card, CardContent } from '$lib/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs'; import { toast } from 'svelte-sonner'; - import { Settings, Globe, Server, Shield, HelpCircle, ScrollText } from '@lucide/svelte'; + import { Settings, Globe, Server, Shield, HelpCircle, ScrollText, Users, KeyRound } from '@lucide/svelte'; import type { ConfigCategory } from '$lib/proto/discopanel/v1/config_pb'; import { rpcClient } from '$lib/api/rpc-client'; import RoutingSettings from '$lib/components/routing-settings.svelte'; import AuthSettings from '$lib/components/auth-settings.svelte'; import SupportSettings from '$lib/components/support-settings.svelte'; import LogsSettings from '$lib/components/logs-settings.svelte'; - + import { + canReadSettings, + canReadUsers, + canReadRoles, + authEnabled, + } from '$lib/stores/auth'; + let globalConfig = $state([]); let loading = $state(true); let saving = $state(false); - let activeTab = $state('server-config'); - + + let showSettings = $derived($canReadSettings); + let showUsers = $derived($canReadUsers && $authEnabled); + let showRoles = $derived($canReadRoles && $authEnabled); + + // Pick the first visible tab as default + let activeTab = $state(''); + $effect(() => { + if (!activeTab) { + if (showSettings) activeTab = 'server-config'; + else if (showUsers) activeTab = 'users'; + else if (showRoles) activeTab = 'roles'; + } + }); + async function loadGlobalSettings() { loading = true; try { @@ -30,7 +51,7 @@ loading = false; } } - + async function saveGlobalSettings(updates: Record) { saving = true; try { @@ -47,9 +68,13 @@ saving = false; } } - + onMount(() => { - loadGlobalSettings(); + if (showSettings) { + loadGlobalSettings(); + } else { + loading = false; + } }); @@ -65,70 +90,98 @@
- - activeTab = v || 'server-config'} class="space-y-6"> - - - - Server Defaults - - - - Routing - - - - Authentication - - - - Logs - - - - Support - + + activeTab = v || activeTab} class="space-y-6"> + + {#if showSettings} + + + Server Defaults + + + + Routing + + + + Auth + + + + Logs + + + + Support + + {/if} + {#if showUsers} + + + Users + + {/if} + {#if showRoles} + + + Roles + + {/if} - - - {#if loading} - - -
-
-
- + + {#if showSettings} + + {#if loading} + + +
+
+
+ +
+
Loading settings...
-
Loading settings...
-
- - - {:else} - - {/if} - - - - - - - - - - - - - - - - - + + + {:else} + + {/if} + + + + + + + + + + + + + + + + + + {/if} + + {#if showUsers} + + + + {/if} + + {#if showRoles} + + + + {/if}
- \ No newline at end of file + From d93b259f7c305acdb4e6299ef0bc4a87bed86721 Mon Sep 17 00:00:00 2001 From: Nicholas Heyer Date: Tue, 17 Feb 2026 18:58:36 -0800 Subject: [PATCH 02/20] Little fixes --- Makefile | 2 +- internal/config/config.go | 20 ++++++++++---------- oidc/authelia/docker-compose.yaml | 9 ++------- oidc/keycloak/docker-compose.yaml | 11 +++-------- 4 files changed, 16 insertions(+), 26 deletions(-) diff --git a/Makefile b/Makefile index 8ae99fd..589583c 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: dev prod clean build build-frontend run deps test fmt lint check help kill-dev image dev-docker dev-auth modules proto proto-clean proto-lint proto-format proto-breaking gen DATA_DIR := ./data -DOCKER_DATA_DIR := /opt/discopanel +DOCKER_DATA_DIR := /tmp/discopanel DB_FILE := $(DATA_DIR)/discopanel.db FRONTEND_DIR := web/discopanel DISCOPANEL_BIN := build/discopanel diff --git a/internal/config/config.go b/internal/config/config.go index 954e2da..4d7feaf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,15 +32,15 @@ type AuthConfig struct { } type OIDCConfig struct { - Enabled bool `mapstructure:"enabled" json:"enabled"` - IssuerURI string `mapstructure:"issuer_uri" json:"issuer_uri"` - ClientID string `mapstructure:"client_id" json:"client_id"` - ClientSecret string `mapstructure:"client_secret" json:"client_secret"` - RedirectURL string `mapstructure:"redirect_url" json:"redirect_url"` - Scopes []string `mapstructure:"scopes" json:"scopes"` - RoleClaim string `mapstructure:"role_claim" json:"role_claim"` - RoleMapping map[string]string `mapstructure:"role_mapping" json:"role_mapping"` - SkipTLSVerify bool `mapstructure:"skip_tls_verify" json:"skip_tls_verify"` + Enabled bool `mapstructure:"enabled" json:"enabled"` + IssuerURI string `mapstructure:"issuer_uri" json:"issuer_uri"` + ClientID string `mapstructure:"client_id" json:"client_id"` + ClientSecret string `mapstructure:"client_secret" json:"client_secret"` + RedirectURL string `mapstructure:"redirect_url" json:"redirect_url"` + Scopes []string `mapstructure:"scopes" json:"scopes"` + RoleClaim string `mapstructure:"role_claim" json:"role_claim"` + RoleMapping map[string]string `mapstructure:"role_mapping" json:"role_mapping"` + SkipTLSVerify bool `mapstructure:"skip_tls_verify" json:"skip_tls_verify"` } type LocalConfig struct { @@ -224,7 +224,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("auth.oidc.client_id", "") v.SetDefault("auth.oidc.client_secret", "") v.SetDefault("auth.oidc.redirect_url", "") - v.SetDefault("auth.oidc.scopes", []string{"openid", "profile", "email", "groups"}) + v.SetDefault("auth.oidc.scopes", []string{"openid", "profile", "email"}) v.SetDefault("auth.oidc.role_claim", "groups") v.SetDefault("auth.oidc.skip_tls_verify", false) v.SetDefault("auth.local.enabled", true) diff --git a/oidc/authelia/docker-compose.yaml b/oidc/authelia/docker-compose.yaml index 047d822..fa893c2 100644 --- a/oidc/authelia/docker-compose.yaml +++ b/oidc/authelia/docker-compose.yaml @@ -10,21 +10,16 @@ services: discopanel: - build: - context: ../../ - dockerfile: docker/Dockerfile.discopanel image: nickheyer/discopanel:dev container_name: discopanel restart: unless-stopped network_mode: host volumes: - /var/run/docker.sock:/var/run/docker.sock - - /opt/discopanel/data:/app/data - - /opt/discopanel/backups:/app/backups - - /tmp/discopanel:/app/tmp + - /tmp/discopanel:/app/data environment: - DISCOPANEL_DATA_DIR=/app/data - - DISCOPANEL_HOST_DATA_PATH=/opt/discopanel/data + - DISCOPANEL_HOST_DATA_PATH=/tmp/discopanel - TZ=UTC # ------------------------------------ AUTH CONFIG STARTS HERE FOR DISCOPANEL + AUTHELIA ------------------------------------ diff --git a/oidc/keycloak/docker-compose.yaml b/oidc/keycloak/docker-compose.yaml index 12369b2..c063775 100644 --- a/oidc/keycloak/docker-compose.yaml +++ b/oidc/keycloak/docker-compose.yaml @@ -8,21 +8,16 @@ services: discopanel: - build: - context: ../../ - dockerfile: docker/Dockerfile.discopanel image: nickheyer/discopanel:dev container_name: discopanel restart: unless-stopped network_mode: host volumes: - /var/run/docker.sock:/var/run/docker.sock - - /opt/discopanel/data:/app/data - - /opt/discopanel/backups:/app/backups - - /tmp/discopanel:/app/tmp + - /tmp/discopanel:/app/data environment: - DISCOPANEL_DATA_DIR=/app/data - - DISCOPANEL_HOST_DATA_PATH=/opt/discopanel/data + - DISCOPANEL_HOST_DATA_PATH=/tmp/discopanel - TZ=UTC # ------------------------------------ AUTH CONFIG STARTS HERE FOR DISCOPANEL + KEYCLOAK ------------------------------------ @@ -53,7 +48,7 @@ services: command: start-dev --import-realm volumes: # THIS IS AN EXAMPLE REALM CONFIG, MODIFY AS NEEDED (WHICH MAY REQUIRE MODIFYING DISCOPANEL CONFIG TO MATCH, SEE ABOVE) - - ./config/*:/opt/keycloak/data/import/.:ro + - ./config/realm.json:/opt/keycloak/data/import/realm.json:ro environment: # KEYCLOAK ADMIN LOGIN CREDENTIALS - CHANGE THESE!!!!! - KC_BOOTSTRAP_ADMIN_USERNAME=admin From 59b3cab91bb790d6686e3a47dcbe15f9a48abfc4 Mon Sep 17 00:00:00 2001 From: Nick Heyer Date: Wed, 18 Feb 2026 00:02:35 -0800 Subject: [PATCH 03/20] fix auth ws --- internal/ws/hub.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 79abb52..5e4edc1 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -32,7 +32,7 @@ const ( // Hub manages WebSocket connections and log subscriptions type Hub struct { - logStreamer *logger.LogStreamer + logStreamer *logger.LogStreamer authManager *auth.Manager enforcer *rbac.Enforcer store *storage.Store @@ -68,7 +68,7 @@ type Client struct { // NewHub creates a new WebSocket hub func NewHub(logStreamer *logger.LogStreamer, authManager *auth.Manager, enforcer *rbac.Enforcer, store *storage.Store, docker *docker.Client, log *logger.Logger) *Hub { return &Hub{ - logStreamer: logStreamer, + logStreamer: logStreamer, authManager: authManager, enforcer: enforcer, store: store, @@ -219,6 +219,19 @@ func (c *Client) handleAuth(msg *v1.AuthMessage) { return } + // If no auth providers are enabled, bypass auth entirely - grant full admin access + if !c.hub.authManager.IsAnyAuthEnabled() { + c.user = &auth.AuthenticatedUser{ + ID: "admin", + Username: "admin", + Roles: []string{"admin"}, + Provider: "none", + } + c.authenticated = true + c.sendAuthOk() + return + } + ctx := context.Background() if msg.Token != "" { From d8f14918a97507f3f7ee2d93a91ad9465788df73 Mon Sep 17 00:00:00 2001 From: Nick Heyer Date: Wed, 18 Feb 2026 00:15:41 -0800 Subject: [PATCH 04/20] Fix mount shenanigans --- docker-compose.yml | 1 - internal/docker/client.go | 2 +- internal/docker/module.go | 31 +++++-------------------------- 3 files changed, 6 insertions(+), 28 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index e2d77b4..36a8658 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,6 @@ services: # DISCOPANEL_HOST_DATA_PATH=/opt/discopanel/data # (See environment) - /opt/discopanel/data:/app/data - - /opt/discopanel/backups:/app/backups - /tmp/discopanel:/app/tmp diff --git a/internal/docker/client.go b/internal/docker/client.go index 53d14fc..4d3df67 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -440,7 +440,7 @@ func (c *Client) CreateContainer(ctx context.Context, server *models.Server, ser hostConfig := &container.HostConfig{ PortBindings: portBindings, Mounts: []mount.Mount{ - {Type: mount.TypeBind, Source: dataPath, Target: "/data"}, + {Type: mount.TypeBind, Source: dataPath, Target: "/data", BindOptions: &mount.BindOptions{CreateMountpoint: true}}, }, RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, Resources: container.Resources{ diff --git a/internal/docker/module.go b/internal/docker/module.go index c0ece50..4ddddf7 100644 --- a/internal/docker/module.go +++ b/internal/docker/module.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" - "path/filepath" shellparse "github.com/arkady-emelyanov/go-shellparse" "github.com/docker/docker/api/types/container" @@ -225,31 +223,12 @@ func (c *Client) parseVolumeMounts(volumeJSON string, aliasCtx *alias.Context) [ continue } - // Handle path translation when DiscoPanel runs in a container - if mountType == mount.TypeBind { - if envHostDataPath := os.Getenv("DISCOPANEL_HOST_DATA_PATH"); envHostDataPath != "" { - containerDataDir := os.Getenv("DISCOPANEL_DATA_DIR") - if containerDataDir == "" { - containerDataDir = "/app/data" - } - if relPath, err := filepath.Rel(containerDataDir, source); err == nil { - source = filepath.Join(envHostDataPath, relPath) - } - } - } - - // Ensure source directory exists for bind mounts - if mountType == mount.TypeBind { - if err := os.MkdirAll(source, 0755); err != nil { - c.log.Warn("Failed to create volume source directory %s: %v", source, err) - } - } - mounts = append(mounts, mount.Mount{ - Type: mountType, - Source: source, - Target: target, - ReadOnly: vol.ReadOnly, + Type: mountType, + Source: source, + Target: target, + ReadOnly: vol.ReadOnly, + BindOptions: &mount.BindOptions{CreateMountpoint: true}, }) } From dce9f9deac8a50be0be6850ce001a5a1e898d541 Mon Sep 17 00:00:00 2001 From: Nick Heyer Date: Wed, 18 Feb 2026 23:17:10 -0800 Subject: [PATCH 05/20] Added scoped user invite feature. Fixed bug regarding modules bind create on read only. Improved auth ui functions. Added invite to rbac mappings. --- internal/db/models.go | 14 + internal/db/store.go | 48 + internal/docker/module.go | 2 +- internal/rbac/mapping.go | 7 +- internal/rbac/rbac.go | 78 +- internal/rpc/services/auth.go | 193 +++- oidc/keycloak/docker-compose.yaml | 5 +- proto/discopanel/v1/auth.proto | 68 ++ .../src/lib/components/user-settings.svelte | 934 ++++++++++++++---- web/discopanel/src/lib/stores/auth.ts | 10 +- web/discopanel/src/routes/login/+page.svelte | 69 +- 11 files changed, 1173 insertions(+), 255 deletions(-) diff --git a/internal/db/models.go b/internal/db/models.go index 5cef598..2bbf3f5 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -386,6 +386,20 @@ type ProxyListener struct { UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } +// RegistrationInvite represents a shareable invite link for controlled registration +type RegistrationInvite struct { + ID string `json:"id" gorm:"primaryKey"` + Code string `json:"code" gorm:"not null;uniqueIndex"` + Description string `json:"description"` + Roles []string `json:"roles" gorm:"column:roles;serializer:json"` + PinHash string `json:"-" gorm:"column:pin_hash"` + MaxUses int `json:"max_uses" gorm:"default:0;column:max_uses"` + UseCount int `json:"use_count" gorm:"default:0;column:use_count"` + ExpiresAt *time.Time `json:"expires_at" gorm:"column:expires_at"` + CreatedBy string `json:"created_by" gorm:"not null;column:created_by"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` +} + // User represents a user account type User struct { ID string `json:"id" gorm:"primaryKey"` diff --git a/internal/db/store.go b/internal/db/store.go index ce56886..576e39e 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -89,6 +89,7 @@ func (s *Store) Migrate() error { &Role{}, &UserRole{}, &Session{}, + &RegistrationInvite{}, &ScheduledTask{}, &TaskExecution{}, &ModuleTemplate{}, @@ -940,6 +941,53 @@ func (s *Store) CleanAllSessions(ctx context.Context) error { return s.db.WithContext(ctx).Where("1 = 1").Delete(&Session{}).Error } +// RegistrationInvite operations +func (s *Store) CreateRegistrationInvite(ctx context.Context, invite *RegistrationInvite) error { + if invite.ID == "" { + invite.ID = uuid.New().String() + } + return s.db.WithContext(ctx).Create(invite).Error +} + +func (s *Store) GetRegistrationInvite(ctx context.Context, id string) (*RegistrationInvite, error) { + var invite RegistrationInvite + err := s.db.WithContext(ctx).First(&invite, "id = ?", id).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("invite not found") + } + return nil, err + } + return &invite, nil +} + +func (s *Store) GetRegistrationInviteByCode(ctx context.Context, code string) (*RegistrationInvite, error) { + var invite RegistrationInvite + err := s.db.WithContext(ctx).First(&invite, "code = ?", code).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("invite not found") + } + return nil, err + } + return &invite, nil +} + +func (s *Store) ListRegistrationInvites(ctx context.Context) ([]*RegistrationInvite, error) { + var invites []*RegistrationInvite + err := s.db.WithContext(ctx).Order("created_at DESC").Find(&invites).Error + return invites, err +} + +func (s *Store) IncrementInviteUseCount(ctx context.Context, id string) error { + return s.db.WithContext(ctx).Model(&RegistrationInvite{}).Where("id = ?", id). + Update("use_count", gorm.Expr("use_count + 1")).Error +} + +func (s *Store) DeleteRegistrationInvite(ctx context.Context, id string) error { + return s.db.WithContext(ctx).Delete(&RegistrationInvite{}, "id = ?", id).Error +} + // SystemSetting operations func (s *Store) GetSystemSetting(ctx context.Context, key string) (string, error) { diff --git a/internal/docker/module.go b/internal/docker/module.go index 4ddddf7..8783e9b 100644 --- a/internal/docker/module.go +++ b/internal/docker/module.go @@ -228,7 +228,7 @@ func (c *Client) parseVolumeMounts(volumeJSON string, aliasCtx *alias.Context) [ Source: source, Target: target, ReadOnly: vol.ReadOnly, - BindOptions: &mount.BindOptions{CreateMountpoint: true}, + BindOptions: &mount.BindOptions{CreateMountpoint: !vol.ReadOnly}, }) } diff --git a/internal/rbac/mapping.go b/internal/rbac/mapping.go index 63eff3a..2165f8d 100644 --- a/internal/rbac/mapping.go +++ b/internal/rbac/mapping.go @@ -12,7 +12,8 @@ var PublicProcedures = map[string]bool{ "/discopanel.v1.AuthService/GetAuthStatus": true, "/discopanel.v1.AuthService/Login": true, "/discopanel.v1.AuthService/Register": true, - "/discopanel.v1.AuthService/GetOIDCLoginURL": true, + "/discopanel.v1.AuthService/GetOIDCLoginURL": true, + "/discopanel.v1.AuthService/ValidateInvite": true, } // AuthenticatedOnlyProcedures lists RPC procedures that require authentication @@ -50,6 +51,10 @@ var ProcedurePermissions = map[string]ProcedurePermission{ // ── AuthService (admin) ─────────────────────────────────────────── "/discopanel.v1.AuthService/GetAuthConfig": {Resource: ResourceSettings, Action: ActionRead}, "/discopanel.v1.AuthService/UpdateAuthSettings": {Resource: ResourceSettings, Action: ActionUpdate}, + "/discopanel.v1.AuthService/CreateInvite": {Resource: ResourceUsers, Action: ActionCreate}, + "/discopanel.v1.AuthService/ListInvites": {Resource: ResourceUsers, Action: ActionRead}, + "/discopanel.v1.AuthService/GetInvite": {Resource: ResourceUsers, Action: ActionRead}, + "/discopanel.v1.AuthService/DeleteInvite": {Resource: ResourceUsers, Action: ActionDelete}, // ── ConfigService ────────────────────────────────────────────────── "/discopanel.v1.ConfigService/GetServerConfig": {Resource: ResourceServerConfig, Action: ActionRead, ObjectIDField: "server_id"}, diff --git a/internal/rbac/rbac.go b/internal/rbac/rbac.go index 88e7e8c..9084de0 100644 --- a/internal/rbac/rbac.go +++ b/internal/rbac/rbac.go @@ -62,54 +62,56 @@ m = g(r.sub, p.sub) && (p.res == "*" || r.res == p.res) && (p.act == "*" || r.ac return &Enforcer{enforcer: e}, nil } -// SeedDefaultPolicies ensures default roles have their base permissions. -// When anonymousEnabled is true, the anonymous role receives read-only -// access to common resources. When false, all anonymous policies are removed. +// Ensures default roles have their base permissions func (e *Enforcer) SeedDefaultPolicies(anonymousEnabled bool) error { - policies := [][]string{ - {"admin", "*", "*", "*"}, - {"user", ResourceServers, ActionRead, "*"}, - {"user", ResourceServers, ActionStart, "*"}, - {"user", ResourceServers, ActionStop, "*"}, - {"user", ResourceServers, ActionRestart, "*"}, - {"user", ResourceServers, ActionCommand, "*"}, - {"user", ResourceServerConfig, ActionRead, "*"}, - {"user", ResourceMods, ActionRead, "*"}, - {"user", ResourceModpacks, ActionRead, "*"}, - {"user", ResourceModules, ActionRead, "*"}, - {"user", ResourceModuleTemplates, ActionRead, "*"}, - {"user", ResourceFiles, ActionRead, "*"}, - {"user", ResourceTasks, ActionRead, "*"}, - {"user", ResourceProxy, ActionRead, "*"}, - {"anonymous", ResourceServers, ActionRead, "*"}, - {"anonymous", ResourceServerConfig, ActionRead, "*"}, - {"anonymous", ResourceMods, ActionRead, "*"}, - {"anonymous", ResourceModpacks, ActionRead, "*"}, - {"anonymous", ResourceModules, ActionRead, "*"}, - {"anonymous", ResourceModuleTemplates, ActionRead, "*"}, - {"anonymous", ResourceFiles, ActionRead, "*"}, - {"anonymous", ResourceTasks, ActionRead, "*"}, - {"anonymous", ResourceProxy, ActionRead, "*"}, + policies := map[string][][]string{ + "admin": { + {"admin", "*", "*", "*"}, + }, + "user": { + {"user", ResourceServers, ActionRead, "*"}, + {"user", ResourceServers, ActionStart, "*"}, + {"user", ResourceServers, ActionStop, "*"}, + {"user", ResourceServers, ActionRestart, "*"}, + {"user", ResourceServers, ActionCommand, "*"}, + {"user", ResourceServerConfig, ActionRead, "*"}, + {"user", ResourceMods, ActionRead, "*"}, + {"user", ResourceModpacks, ActionRead, "*"}, + {"user", ResourceModules, ActionRead, "*"}, + {"user", ResourceModuleTemplates, ActionRead, "*"}, + {"user", ResourceFiles, ActionRead, "*"}, + {"user", ResourceTasks, ActionRead, "*"}, + {"user", ResourceProxy, ActionRead, "*"}, + }, + "anonymous": { + {"anonymous", ResourceServers, ActionRead, "*"}, + {"anonymous", ResourceServerConfig, ActionRead, "*"}, + {"anonymous", ResourceMods, ActionRead, "*"}, + {"anonymous", ResourceModpacks, ActionRead, "*"}, + {"anonymous", ResourceModules, ActionRead, "*"}, + {"anonymous", ResourceModuleTemplates, ActionRead, "*"}, + {"anonymous", ResourceFiles, ActionRead, "*"}, + {"anonymous", ResourceTasks, ActionRead, "*"}, + {"anonymous", ResourceProxy, ActionRead, "*"}, + }, } - for _, p := range policies { - if p[0] == "anonymous" && !anonymousEnabled { - continue - } - has, err := e.enforcer.HasPolicy(p[0], p[1], p[2], p[3]) + + for role, rolePolicies := range policies { + existing, err := e.enforcer.GetFilteredPolicy(0, role) if err != nil { return err } - if !has { - if _, err = e.enforcer.AddPolicy(p[0], p[1], p[2], p[3]); err != nil { + if len(existing) > 0 { + continue + } + + for _, p := range rolePolicies { + if _, err := e.enforcer.AddPolicy(p[0], p[1], p[2], p[3]); err != nil { return err } } } - if !anonymousEnabled { - e.enforcer.RemoveFilteredPolicy(0, "anonymous") - } - return e.enforcer.SavePolicy() } diff --git a/internal/rpc/services/auth.go b/internal/rpc/services/auth.go index 506253a..7a756e5 100644 --- a/internal/rpc/services/auth.go +++ b/internal/rpc/services/auth.go @@ -2,10 +2,15 @@ package services import ( "context" + "crypto/rand" + "encoding/base64" "errors" "strings" + "time" "connectrpc.com/connect" + "golang.org/x/crypto/bcrypt" + "github.com/nickheyer/discopanel/internal/auth" storage "github.com/nickheyer/discopanel/internal/db" "github.com/nickheyer/discopanel/internal/rbac" @@ -101,11 +106,33 @@ func (s *AuthService) Logout(ctx context.Context, req *connect.Request[v1.Logout func (s *AuthService) Register(ctx context.Context, req *connect.Request[v1.RegisterRequest]) (*connect.Response[v1.RegisterResponse], error) { msg := req.Msg - // Check if registration is allowed userCount, _ := s.store.CountUsers(ctx) isFirstUser := userCount == 0 - if !isFirstUser && !s.authManager.IsRegistrationAllowed() { + var invite *storage.RegistrationInvite + + if msg.InviteCode != nil && *msg.InviteCode != "" { + // Validate invite + var err error + invite, err = s.store.GetRegistrationInviteByCode(ctx, *msg.InviteCode) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, errors.New("invalid invite code")) + } + if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now()) { + return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("invite has expired")) + } + if invite.MaxUses > 0 && invite.UseCount >= invite.MaxUses { + return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("invite has reached maximum uses")) + } + if invite.PinHash != "" { + if msg.InvitePin == nil || *msg.InvitePin == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("PIN is required for this invite")) + } + if err := bcrypt.CompareHashAndPassword([]byte(invite.PinHash), []byte(*msg.InvitePin)); err != nil { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("incorrect PIN")) + } + } + } else if !isFirstUser && !s.authManager.IsRegistrationAllowed() { return nil, connect.NewError(connect.CodePermissionDenied, errors.New("registration is disabled")) } @@ -119,9 +146,13 @@ func (s *AuthService) Register(ctx context.Context, req *connect.Request[v1.Regi return nil, connect.NewError(connect.CodeAlreadyExists, errors.New("registration failed")) } - // First user gets admin role, others get default roles + // Role assignment: first user → admin; invite with roles → invite roles; else → default roles if isFirstUser { _ = s.store.AssignRole(ctx, user.ID, "admin", "local") + } else if invite != nil && len(invite.Roles) > 0 { + for _, roleName := range invite.Roles { + _ = s.store.AssignRole(ctx, user.ID, roleName, "invite") + } } else { defaultRoles, _ := s.store.GetDefaultRoles(ctx) for _, role := range defaultRoles { @@ -129,6 +160,11 @@ func (s *AuthService) Register(ctx context.Context, req *connect.Request[v1.Regi } } + // Increment invite use count after successful registration + if invite != nil { + _ = s.store.IncrementInviteUseCount(ctx, invite.ID) + } + roles, _ := s.store.GetUserRoleNames(ctx, user.ID) return connect.NewResponse(&v1.RegisterResponse{ @@ -292,3 +328,154 @@ func (s *AuthService) UpdateAuthSettings(ctx context.Context, req *connect.Reque Config: configResp.Msg, }), nil } + +func (s *AuthService) CreateInvite(ctx context.Context, req *connect.Request[v1.CreateInviteRequest]) (*connect.Response[v1.CreateInviteResponse], error) { + msg := req.Msg + + // Validate roles exist + if len(msg.Roles) > 0 { + existingRoles, err := s.store.ListRoles(ctx) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to list roles")) + } + roleSet := make(map[string]bool, len(existingRoles)) + for _, r := range existingRoles { + roleSet[r.Name] = true + } + for _, roleName := range msg.Roles { + if !roleSet[roleName] { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role not found: "+roleName)) + } + } + } + + // Generate crypto-random code + codeBytes := make([]byte, 32) + if _, err := rand.Read(codeBytes); err != nil { + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to generate invite code")) + } + code := base64.RawURLEncoding.EncodeToString(codeBytes) + + // Hash PIN if provided + var pinHash string + if msg.Pin != nil && *msg.Pin != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(*msg.Pin), bcrypt.DefaultCost) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to hash PIN")) + } + pinHash = string(hash) + } + + // Calculate expiration + var expiresAt *time.Time + if msg.ExpiresInHours != nil && *msg.ExpiresInHours > 0 { + t := time.Now().Add(time.Duration(*msg.ExpiresInHours) * time.Hour) + expiresAt = &t + } + + // Get creator from context + authUser := auth.GetUserFromContext(ctx) + createdBy := "" + if authUser != nil { + createdBy = authUser.Username + } + + invite := &storage.RegistrationInvite{ + Code: code, + Description: msg.Description, + Roles: msg.Roles, + PinHash: pinHash, + MaxUses: int(msg.MaxUses), + ExpiresAt: expiresAt, + CreatedBy: createdBy, + } + + if err := s.store.CreateRegistrationInvite(ctx, invite); err != nil { + s.log.Error("Failed to create invite: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to create invite")) + } + + return connect.NewResponse(&v1.CreateInviteResponse{ + Invite: dbInviteToProto(invite), + }), nil +} + +func (s *AuthService) ListInvites(ctx context.Context, req *connect.Request[v1.ListInvitesRequest]) (*connect.Response[v1.ListInvitesResponse], error) { + invites, err := s.store.ListRegistrationInvites(ctx) + if err != nil { + s.log.Error("Failed to list invites: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to list invites")) + } + + protoInvites := make([]*v1.RegistrationInvite, 0, len(invites)) + for _, inv := range invites { + protoInvites = append(protoInvites, dbInviteToProto(inv)) + } + + return connect.NewResponse(&v1.ListInvitesResponse{ + Invites: protoInvites, + }), nil +} + +func (s *AuthService) GetInvite(ctx context.Context, req *connect.Request[v1.GetInviteRequest]) (*connect.Response[v1.GetInviteResponse], error) { + invite, err := s.store.GetRegistrationInvite(ctx, req.Msg.Id) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, errors.New("invite not found")) + } + + return connect.NewResponse(&v1.GetInviteResponse{ + Invite: dbInviteToProto(invite), + }), nil +} + +func (s *AuthService) DeleteInvite(ctx context.Context, req *connect.Request[v1.DeleteInviteRequest]) (*connect.Response[v1.DeleteInviteResponse], error) { + if err := s.store.DeleteRegistrationInvite(ctx, req.Msg.Id); err != nil { + s.log.Error("Failed to delete invite: %v", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to delete invite")) + } + + return connect.NewResponse(&v1.DeleteInviteResponse{}), nil +} + +func (s *AuthService) ValidateInvite(ctx context.Context, req *connect.Request[v1.ValidateInviteRequest]) (*connect.Response[v1.ValidateInviteResponse], error) { + if req.Msg.Code == "" { + return connect.NewResponse(&v1.ValidateInviteResponse{Valid: false}), nil + } + + invite, err := s.store.GetRegistrationInviteByCode(ctx, req.Msg.Code) + if err != nil { + return connect.NewResponse(&v1.ValidateInviteResponse{Valid: false}), nil + } + + if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now()) { + return connect.NewResponse(&v1.ValidateInviteResponse{Valid: false}), nil + } + + if invite.MaxUses > 0 && invite.UseCount >= invite.MaxUses { + return connect.NewResponse(&v1.ValidateInviteResponse{Valid: false}), nil + } + + return connect.NewResponse(&v1.ValidateInviteResponse{ + Valid: true, + RequiresPin: invite.PinHash != "", + Description: invite.Description, + }), nil +} + +func dbInviteToProto(invite *storage.RegistrationInvite) *v1.RegistrationInvite { + pi := &v1.RegistrationInvite{ + Id: invite.ID, + Code: invite.Code, + Description: invite.Description, + Roles: invite.Roles, + HasPin: invite.PinHash != "", + MaxUses: int32(invite.MaxUses), + UseCount: int32(invite.UseCount), + CreatedBy: invite.CreatedBy, + CreatedAt: timestamppb.New(invite.CreatedAt), + } + if invite.ExpiresAt != nil { + pi.ExpiresAt = timestamppb.New(*invite.ExpiresAt) + } + return pi +} diff --git a/oidc/keycloak/docker-compose.yaml b/oidc/keycloak/docker-compose.yaml index c063775..b43dde9 100644 --- a/oidc/keycloak/docker-compose.yaml +++ b/oidc/keycloak/docker-compose.yaml @@ -8,7 +8,10 @@ services: discopanel: - image: nickheyer/discopanel:dev + build: + context: ../../ + dockerfile: docker/Dockerfile.discopanel + #image: nickheyer/discopanel:dev container_name: discopanel restart: unless-stopped network_mode: host diff --git a/proto/discopanel/v1/auth.proto b/proto/discopanel/v1/auth.proto index 36c307e..138ebd3 100644 --- a/proto/discopanel/v1/auth.proto +++ b/proto/discopanel/v1/auth.proto @@ -27,6 +27,16 @@ service AuthService { rpc GetAuthConfig(GetAuthConfigRequest) returns (GetAuthConfigResponse); // Update mutable auth settings rpc UpdateAuthSettings(UpdateAuthSettingsRequest) returns (UpdateAuthSettingsResponse); + // Create a registration invite link + rpc CreateInvite(CreateInviteRequest) returns (CreateInviteResponse); + // List all registration invites + rpc ListInvites(ListInvitesRequest) returns (ListInvitesResponse); + // Get a specific invite by ID + rpc GetInvite(GetInviteRequest) returns (GetInviteResponse); + // Delete/revoke an invite + rpc DeleteInvite(DeleteInviteRequest) returns (DeleteInviteResponse); + // Validate an invite code (public, unauthenticated) + rpc ValidateInvite(ValidateInviteRequest) returns (ValidateInviteResponse); } // Empty auth status request @@ -67,6 +77,8 @@ message RegisterRequest { string username = 1; string email = 2; string password = 3; + optional string invite_code = 4; + optional string invite_pin = 5; } // Created user @@ -133,3 +145,59 @@ message UpdateAuthSettingsRequest { message UpdateAuthSettingsResponse { GetAuthConfigResponse config = 1; } + +// Registration invite link +message RegistrationInvite { + string id = 1; + string code = 2; + string description = 3; + repeated string roles = 4; + bool has_pin = 5; + int32 max_uses = 6; + int32 use_count = 7; + google.protobuf.Timestamp expires_at = 8; + string created_by = 9; + google.protobuf.Timestamp created_at = 10; +} + +message CreateInviteRequest { + string description = 1; + repeated string roles = 2; + optional string pin = 3; + int32 max_uses = 4; + optional int32 expires_in_hours = 5; +} + +message CreateInviteResponse { + RegistrationInvite invite = 1; +} + +message ListInvitesRequest {} + +message ListInvitesResponse { + repeated RegistrationInvite invites = 1; +} + +message GetInviteRequest { + string id = 1; +} + +message GetInviteResponse { + RegistrationInvite invite = 1; +} + +message DeleteInviteRequest { + string id = 1; +} + +message DeleteInviteResponse {} + +message ValidateInviteRequest { + string code = 1; +} + +message ValidateInviteResponse { + bool valid = 1; + bool requires_pin = 2; + string description = 3; +} diff --git a/web/discopanel/src/lib/components/user-settings.svelte b/web/discopanel/src/lib/components/user-settings.svelte index 414ab03..451dd70 100644 --- a/web/discopanel/src/lib/components/user-settings.svelte +++ b/web/discopanel/src/lib/components/user-settings.svelte @@ -1,19 +1,23 @@
-

Manage user accounts and role assignments

- {#if canCreate} - - {/if} +

Manage user accounts, role assignments, and invite links

+
+ {#if canCreate} + + + {/if} +
- - - {#if loading} -
-
- -
Loading users...
+
+ + + + {#if loading} +
+
+ +
Loading users...
+
-
- {:else if users.length === 0} -
-
- -
No users found
+ {:else if users.length === 0} +
+
+ +
No users found
+
-
- {:else} - - - - Username - Email - Provider - Roles - Status - Created - {#if canUpdate || canDelete} - Actions - {/if} - - - - {#each users as user} + {:else} +
+ - {user.username} - {user.email || '-'} - - {user.authProvider || 'local'} - - -
- {#each user.roles || [] as role} - - {role} - - {/each} - {#if !user.roles?.length} - None - {/if} -
-
- - {#if user.isActive} - Active - {:else} - Inactive - {/if} - - - {user.createdAt ? formatDate(new Date(Number(user.createdAt.seconds) * 1000).toISOString()) : 'Unknown'} - + Username + Email + Provider + Roles + Status + Created {#if canUpdate || canDelete} - -
- {#if canUpdate} - - {/if} - {#if canDelete} - + Actions + {/if} + + + + {#each users as user} + + {user.username} + {user.email || '-'} + + {user.authProvider || 'local'} + + +
+ {#each user.roles || [] as role} + + {role} + + {/each} + {#if !user.roles?.length} + None {/if}
- {/if} -
+ + {#if user.isActive} + Active + {:else} + Inactive + {/if} + + + {user.createdAt ? formatDate(new Date(Number(user.createdAt.seconds) * 1000).toISOString()) : 'Unknown'} + + {#if canUpdate || canDelete} + +
+ {#if canUpdate} + + {/if} + {#if canDelete} + + {/if} +
+
+ {/if} + + {/each} +
+
+ {/if} + + + + + + +
+ +
+ Invite Links + Controlled registration via shareable URLs +
+
+
+ + {#if invitesLoading} +
+ +
+ {:else if invites.length === 0} +
+ +

+ No invite links yet. Create one to allow controlled registration. +

+
+ {:else} +
+ {#each invites as invite (invite.id)} + {@const status = getInviteStatus(invite)} +
+
+
+ {invite.description || 'Untitled'} + + {status} + + {#if invite.hasPin} + PIN + {/if} +
+
+ + {#if canDelete} + + {/if} +
+
+
+ {invite.useCount}{invite.maxUses > 0 ? `/${invite.maxUses}` : '/\u221e'} uses + {#if invite.roles && invite.roles.length > 0} + | + {invite.roles.join(', ')} + {/if} + {#if invite.expiresAt} + | + {new Date(Number(invite.expiresAt.seconds) * 1000).toLocaleDateString()} + {/if} +
+
{/each} - - - {/if} - - +
+ {/if} +
+
+
showCreateDialog = open}> - - - Create New User - - Add a new user to the system with specific roles. - - - -
-
- - -
-
- - -
-
- - + +
+ +
+
+
+
+ +
+
+

New User

+

Local account

+
+
+
+ +
+
+
+ +

Creates a local account with password authentication.

+
+
+ +

Assign roles to control what the user can access.

+
+
+ +

Email is optional and used for display purposes only.

+
+
+
+ +
+
+

Password policy

+

+ Passwords must be at least 8 characters. The user can change their password later. +

+
+
-
- -
- {#each availableRoles as role} - - {/each} + + +
+
+
+

Create User

+

Add a new user to the system

+
+ +
+ +
+
+
+ + +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+
+ {#each availableRoles as role} + + {/each} +
+
+
+
+ +
+ +
- - - - -
showEditDialog = open}> - - - Edit User - - Update user information and role assignments. - - - - {#if editingUser} -
-
- - + +
+ +
+
+
+
+ +
+
+

{editingUser?.username ?? 'User'}

+

{editingUser?.authProvider || 'local'} account

+
+
-
- - + +
+ {#if editingUser} +
+
+ +
+

Current roles

+
+ {#each editingUser.roles || [] as role} + {role} + {/each} + {#if !editingUser.roles?.length} + None + {/if} +
+
+
+
+ +
+

Created

+

+ {editingUser.createdAt ? formatDate(new Date(Number(editingUser.createdAt.seconds) * 1000).toISOString()) : 'Unknown'} +

+
+
+
+ {/if}
-
- -
- {#each availableRoles as role} - - {/each} + +
+
+

User management

+

+ Changes to roles and status take effect immediately. The username cannot be changed. +

-
- - +
+ + +
+
+
+

Edit User

+

Update account details and permissions

+
+ +
+ +
+ {#if editingUser} +
+
+ + +
+
+
+ + +
+ +
+
+
+ + +
+
+ {#each availableRoles as role} + + {/each} +
+
+
+
+ +

Inactive accounts cannot log in

+
+ editUserForm.isActive = checked} + /> +
+
+ {/if} +
+ +
+ + +
+
+
+ +
+ + + showCreateInviteDialog = open}> + +
+ +
+ +
+
+
+ +
+
+

New Invite

+

Registration link

+
+
+
+ + +
+
+
+ +

Generates a unique URL that allows someone to register an account.

+
+
+ +

Assigned roles are applied automatically on registration.

+
+
+ +

Optional PIN adds an extra layer of protection.

+
+
+
+ + +
+
+

How it works

+

+ After creation, the invite URL is copied to your clipboard. Share it with the person you want to invite. +

+
+
+
+ + +
+ +
+
+

Create Invite

+

Configure invite settings and restrictions

+
+ +
+ + +
+
+
+ + +
+ +
+ +

Assigned on registration. If none selected, default roles are used.

+
+ {#each inviteRoleNames as role} + + {/each} +
+
+ +
+
+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+ +

If set, users must enter this PIN to use the invite.

+
+
+
+ + +
+ +
- {/if} - - - - - +
diff --git a/web/discopanel/src/lib/stores/auth.ts b/web/discopanel/src/lib/stores/auth.ts index ddf94c9..b35ecf8 100644 --- a/web/discopanel/src/lib/stores/auth.ts +++ b/web/discopanel/src/lib/stores/auth.ts @@ -184,8 +184,14 @@ function createAuthStore() { goto('/login'); }, - async register(username: string, email: string, password: string) { - const request = create(RegisterRequestSchema, { username, email, password }); + async register(username: string, email: string, password: string, inviteCode?: string, invitePin?: string) { + const request = create(RegisterRequestSchema, { + username, + email, + password, + inviteCode: inviteCode || undefined, + invitePin: invitePin || undefined, + }); await rpcClient.auth.register(request); // After successful registration, log them in diff --git a/web/discopanel/src/routes/login/+page.svelte b/web/discopanel/src/routes/login/+page.svelte index 5fde465..b657bdf 100644 --- a/web/discopanel/src/routes/login/+page.svelte +++ b/web/discopanel/src/routes/login/+page.svelte @@ -1,7 +1,11 @@ + + + +
+ + + diff --git a/docs/discopanel/src/content/docs/api.md b/docs/discopanel/src/content/docs/api.md deleted file mode 100644 index 4234e3d..0000000 --- a/docs/discopanel/src/content/docs/api.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: API Docs -description: DiscoPanel REST API reference. ---- - -DiscoPanel exposes a full REST API via Connect RPC. Every running DiscoPanel instance includes an interactive API viewer (Scalar) in the web UI sidebar. - -A hosted version of the API docs will be available here at [docs.discopanel.app](https://docs.discopanel.app) as well. - -*This section is a work in progress.* diff --git a/docs/discopanel/src/content/docs/api.mdx b/docs/discopanel/src/content/docs/api.mdx new file mode 100644 index 0000000..c2751d9 --- /dev/null +++ b/docs/discopanel/src/content/docs/api.mdx @@ -0,0 +1,35 @@ +--- +title: API Reference +description: Interactive DiscoPanel API documentation powered by Scalar. +template: splash +hero: + tagline: Explore every endpoint in the DiscoPanel Connect RPC API. This spec is auto-generated from the protobuf definitions and stays in sync with the latest release. + actions: + - text: Back to Docs + link: /introduction/ + icon: left-arrow + variant: minimal +--- + + + +
+ +
From 0a6a4d253f91a73792b369c07d276797b4e8cfd9 Mon Sep 17 00:00:00 2001 From: Nick Heyer Date: Tue, 24 Feb 2026 01:36:02 -0800 Subject: [PATCH 12/20] Created a proper getting-started page. --- .github/workflows/release.yml | 2 +- Makefile | 2 +- .../src/content/docs/getting-started.md | 8 - .../src/content/docs/getting-started.mdx | 157 ++++++++++++++++++ 4 files changed, 159 insertions(+), 10 deletions(-) delete mode 100644 docs/discopanel/src/content/docs/getting-started.md create mode 100644 docs/discopanel/src/content/docs/getting-started.mdx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d4928d..e3d7de1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -161,7 +161,7 @@ jobs: CC: ${{ matrix.cc || '' }} CXX: ${{ matrix.cxx || '' }} run: | - go build -tags embed -o discopanel-${{ matrix.suffix }} ./cmd/discopanel + go build -o discopanel-${{ matrix.suffix }} ./cmd/discopanel - name: Compress binary (non-Windows) if: matrix.goos != 'windows' diff --git a/Makefile b/Makefile index 8acfab8..7f9f950 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,7 @@ dev-docs: prod: build-frontend @echo "Building for production..." @mkdir -p $(DATA_DIR) - go build -tags embed -o $(DISCOPANEL_BIN) cmd/discopanel/main.go + go build -o $(DISCOPANEL_BIN) cmd/discopanel/main.go # Build frontend for production build-frontend: diff --git a/docs/discopanel/src/content/docs/getting-started.md b/docs/discopanel/src/content/docs/getting-started.md deleted file mode 100644 index 5041480..0000000 --- a/docs/discopanel/src/content/docs/getting-started.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: Getting Started -description: Deployment options and first steps. ---- - -Deployment guides for running DiscoPanel via Docker, Docker Compose, Proxmox, directly on the host, or building from source. - -*This section is a work in progress.* diff --git a/docs/discopanel/src/content/docs/getting-started.mdx b/docs/discopanel/src/content/docs/getting-started.mdx new file mode 100644 index 0000000..98a62fd --- /dev/null +++ b/docs/discopanel/src/content/docs/getting-started.mdx @@ -0,0 +1,157 @@ +--- +title: Getting Started +description: Deploy DiscoPanel via Docker Compose, Proxmox LXC, or build from source. +--- + +import { Code } from '@astrojs/starlight/components'; +import dockerCompose from '../../../../../docker-compose.yml?raw'; +import configExample from '../../../../../config.example.yaml?raw'; + +## Prerequisites + +DiscoPanel manages Minecraft servers as Docker containers, so Docker is required regardless of how you deploy DiscoPanel itself. + +- [Docker Engine](https://docs.docker.com/engine/install/) (with Docker Compose for that method) + +## Docker Compose + +The recommended way to run DiscoPanel. Create a `docker-compose.yml` or use the one from the repo: + + + +Spin up your service: + +```bash +docker compose up -d +``` + +### Volume paths + +DiscoPanel creates Docker bind mounts for each managed Minecraft server. When DiscoPanel runs inside a container, it needs to know both the container-internal path and the corresponding host path to set up those mounts correctly. + +| Variable | Purpose | Example | +|---|---|---| +| `DISCOPANEL_HOST_DATA_PATH` | The host path that `DISCOPANEL_DATA_DIR` is mounted from | `/opt/discopanel/data` | +| `DISCOPANEL_DATA_DIR` | Where DiscoPanel reads/writes data inside the container — you can usually leave this as the default | `/app/data` | + +These **must** correspond to the same volume entry. If your compose file maps `/srv/minecraft/data:/app/data`, then set `DISCOPANEL_HOST_DATA_PATH=/srv/minecraft/data`. + +:::caution[SELinux] +On Fedora, RHEL, CentOS, etc., append `:z` to volume mounts: +```yaml +- /var/run/docker.sock:/var/run/docker.sock:z +``` +::: + +Once running, open **http://\:8080** and create your admin account. + +--- + +## Proxmox LXC + +The [Proxmox VE Community Scripts](https://community-scripts.github.io/ProxmoxVE/) project maintains a [DiscoPanel helper script](https://community-scripts.github.io/ProxmoxVE/scripts?id=discopanel) that creates a Debian 13 LXC container with everything pre-installed (Docker, Go, Node.js, DiscoPanel built from source) and managed as a systemd service. + +From your **Proxmox host shell**: + +```bash +bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/discopanel.sh)" +``` + +The interactive wizard lets you adjust the default container resources. Set these based on what you expect your servers to need. + +### Sizing guidance + +**CPU** — A single Minecraft server (vanilla or modded) rarely exceeds 400% (4 cores). While up to 4 cores per server is recommended for optimal performance, you can start with the default of `4` and increase as needed. + +**RAM** — Vanilla servers may only need a few GB, but heavily modded servers average 10 GB+ per instance, especially with multiple players. You can safely allocate most of your available RAM — Proxmox doesn't strictly reserve it, so other LXCs and the host OS can still use whatever DiscoPanel isn't actively consuming. + +**Disk** — Entirely depends on what you plan to run and for how long. 40–80 GB is a reasonable starting point and can be expanded later. + +### Paths + +Since DiscoPanel runs directly on the host here (not inside a Docker container), file paths are straightforward — `DISCOPANEL_DATA_DIR` and `DISCOPANEL_HOST_DATA_PATH` should be the same value. The default `./data` relative to `/opt/discopanel` works out of the box. + +Once complete, DiscoPanel is accessible at **http://\:8080**. + +--- + +## Prebuilt Binaries + +Grab a release from the [releases page](https://github.com/nickheyer/discopanel/releases). Packages are available for each platform: + +``` +discopanel-darwin-amd64.tar.gz +discopanel-darwin-arm64.tar.gz +discopanel-linux-amd64.tar.gz +discopanel-linux-arm64.tar.gz +discopanel-windows-amd64.exe.zip +``` + +:::note +Darwin is the platform name for macOS. Only Linux is fully supported and tested — Windows installations may have issues. +::: + +Extract and skip ahead to [Running](#running). + +--- + +## Building from Source + +If you'd rather build it yourself instead of using a prebuilt binary: + +### Requirements + +- **Go** 1.24+ +- **Node.js** 22+ +- **Docker** (used to run [buf](https://buf.build/) for protobuf code generation) +- **Make** + +### Build steps + +```bash +# Clone the repo +git clone https://github.com/nickheyer/discopanel.git +cd discopanel + +# Generate protobuf code (Go + TypeScript) via Docker buf +make gen + +# Install frontend dependencies and build +cd web/discopanel && npm install && npm run build && cd ../.. + +# Build the Go binary with embedded frontend +go build -o discopanel cmd/discopanel/main.go +``` + +The `//go:embed` directives bake the frontend build into the binary so it serves the UI without a separate web server. + +--- + +## Running + +```bash +# Using default config (./config.yaml if it exists, otherwise defaults) +./discopanel + +# Or point to a specific config file +./discopanel -config /path/to/config.yaml +``` + +All config options can also be set via environment variables with the `DISCOPANEL_` prefix. For example, `server.port` becomes `DISCOPANEL_SERVER_PORT`. Environment variables take precedence over the config file. + +:::tip[Host path mapping] +When running the binary directly on a host (not in a Docker container), do **not** set `DISCOPANEL_DATA_DIR` or `DISCOPANEL_HOST_DATA_PATH` — those are only for container path translation. + +To change where DiscoPanel stores its database and server data, set `DISCOPANEL_STORAGE_DATA_DIR` or configure it in your config file: + +```yaml +storage: + data_dir: /your/path # Default: ./data (relative to working directory) +``` +::: + +### Reference config + +All available options with defaults: + + From b85ae0e945446dfffea0091453b8647f64a1cd4d Mon Sep 17 00:00:00 2001 From: Nick Heyer Date: Tue, 24 Feb 2026 01:54:06 -0800 Subject: [PATCH 13/20] Improved docs --- config.example.yaml | 4 +- docs/discopanel/src/content/docs/api.mdx | 21 ++++------ .../src/content/docs/configuration.md | 10 ----- .../src/content/docs/configuration.mdx | 41 +++++++++++++++++++ .../src/content/docs/getting-started.mdx | 7 +--- docs/discopanel/src/content/docs/index.mdx | 2 +- 6 files changed, 54 insertions(+), 31 deletions(-) delete mode 100644 docs/discopanel/src/content/docs/configuration.md create mode 100644 docs/discopanel/src/content/docs/configuration.mdx diff --git a/config.example.yaml b/config.example.yaml index 7e29e74..484d579 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -17,6 +17,7 @@ server: read_timeout: 15 write_timeout: 15 idle_timeout: 60 + user_agent: "DiscoPanel/1.0 (github.com/nickheyer/discopanel)" # Database configuration database: @@ -58,9 +59,10 @@ auth: client_id: "" # Client ID registered with your OIDC (like "discopanel") client_secret: "" # Client secret registered with your OIDC redirect_url: "" # Where OIDC sends users after login (ie: "http://localhost:8080/api/v1/auth/oidc/callback") - scopes: ["openid", "profile", "email", "groups"] + scopes: ["openid", "profile", "email"] role_claim: "groups" # The token claim that contains the user's groups (usually "groups") role_mapping: {} # Mapping to groups if they arent the same name as discopanels (ie: {"my-admins": "admin", "my-users": "user"}) + skip_tls_verify: false # Skip TLS certificate verification (for self-signed certs) # Upload configuration upload: diff --git a/docs/discopanel/src/content/docs/api.mdx b/docs/discopanel/src/content/docs/api.mdx index c2751d9..6330114 100644 --- a/docs/discopanel/src/content/docs/api.mdx +++ b/docs/discopanel/src/content/docs/api.mdx @@ -1,27 +1,22 @@ --- title: API Reference description: Interactive DiscoPanel API documentation powered by Scalar. -template: splash -hero: - tagline: Explore every endpoint in the DiscoPanel Connect RPC API. This spec is auto-generated from the protobuf definitions and stays in sync with the latest release. - actions: - - text: Back to Docs - link: /introduction/ - icon: left-arrow - variant: minimal +tableOfContents: false --- +This spec is auto-generated from the protobuf definitions and stays in sync with the latest release. +