+
+
+
+
+
+
+
+
+
+
diff --git a/docs/discopanel/public/discopanel_logo_transparent_512.png b/docs/discopanel/public/discopanel_logo_transparent_512.png
new file mode 100644
index 0000000..629501a
Binary files /dev/null and b/docs/discopanel/public/discopanel_logo_transparent_512.png differ
diff --git a/docs/discopanel/public/favicon.png b/docs/discopanel/public/favicon.png
new file mode 100644
index 0000000..270c0b6
Binary files /dev/null and b/docs/discopanel/public/favicon.png differ
diff --git a/docs/discopanel/public/mc_diamond_ore_block_texture.png b/docs/discopanel/public/mc_diamond_ore_block_texture.png
new file mode 100644
index 0000000..b61b129
Binary files /dev/null and b/docs/discopanel/public/mc_diamond_ore_block_texture.png differ
diff --git a/docs/discopanel/public/mc_dirt_block_texture.png b/docs/discopanel/public/mc_dirt_block_texture.png
new file mode 100644
index 0000000..ef900e2
Binary files /dev/null and b/docs/discopanel/public/mc_dirt_block_texture.png differ
diff --git a/docs/discopanel/public/mc_lava_block_texture.png b/docs/discopanel/public/mc_lava_block_texture.png
new file mode 100644
index 0000000..c68650c
Binary files /dev/null and b/docs/discopanel/public/mc_lava_block_texture.png differ
diff --git a/docs/discopanel/public/mc_netherrack_block_texture.png b/docs/discopanel/public/mc_netherrack_block_texture.png
new file mode 100644
index 0000000..c8b18fe
Binary files /dev/null and b/docs/discopanel/public/mc_netherrack_block_texture.png differ
diff --git a/docs/discopanel/public/mc_obsidian_block_texture.png b/docs/discopanel/public/mc_obsidian_block_texture.png
new file mode 100644
index 0000000..a38c070
Binary files /dev/null and b/docs/discopanel/public/mc_obsidian_block_texture.png differ
diff --git a/docs/discopanel/src/content.config.ts b/docs/discopanel/src/content.config.ts
new file mode 100644
index 0000000..d9ee8c9
--- /dev/null
+++ b/docs/discopanel/src/content.config.ts
@@ -0,0 +1,7 @@
+import { defineCollection } from 'astro:content';
+import { docsLoader } from '@astrojs/starlight/loaders';
+import { docsSchema } from '@astrojs/starlight/schema';
+
+export const collections = {
+ docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
+};
diff --git a/docs/discopanel/src/content/docs/api.mdx b/docs/discopanel/src/content/docs/api.mdx
new file mode 100644
index 0000000..6330114
--- /dev/null
+++ b/docs/discopanel/src/content/docs/api.mdx
@@ -0,0 +1,30 @@
+---
+title: API Reference
+description: Interactive DiscoPanel API documentation powered by Scalar.
+tableOfContents: false
+---
+
+This spec is auto-generated from the protobuf definitions and stays in sync with the latest release.
+
+
+
+
+
+
diff --git a/docs/discopanel/src/content/docs/configuration.mdx b/docs/discopanel/src/content/docs/configuration.mdx
new file mode 100644
index 0000000..22cae58
--- /dev/null
+++ b/docs/discopanel/src/content/docs/configuration.mdx
@@ -0,0 +1,41 @@
+---
+title: Configuration
+description: DiscoPanel settings, config files, and environment variables.
+---
+
+import { Code } from '@astrojs/starlight/components';
+import configExample from '../../../../../config.example.yaml?raw';
+
+DiscoPanel can be configured via a YAML config file, environment variables, or both. Environment variables take precedence over the config file.
+
+For Minecraft server-specific configuration (server.properties, JVM flags, mod loader settings, etc.), see the [itzg/minecraft-server docs](https://docker-minecraft-server.readthedocs.io/).
+
+## Config file
+
+DiscoPanel looks for `config.yaml` in these locations (in order):
+
+1. Path passed via `-config` flag
+2. Current working directory (`./`)
+3. `./config/`
+4. `/etc/discopanel/`
+
+If no config file is found, defaults are used.
+
+## Environment variables
+
+Every config option can be set via an environment variable with the `DISCOPANEL_` prefix. Nested keys are separated by underscores:
+
+```yaml
+server:
+ port: "8080"
+```
+
+is equivalent to:
+
+```bash
+DISCOPANEL_SERVER_PORT="8080"
+```
+
+## All options
+
+
diff --git a/docs/discopanel/src/content/docs/contributing.md b/docs/discopanel/src/content/docs/contributing.md
new file mode 100644
index 0000000..5d86ddc
--- /dev/null
+++ b/docs/discopanel/src/content/docs/contributing.md
@@ -0,0 +1,8 @@
+---
+title: Contributing
+description: Guidelines for contributing to DiscoPanel.
+---
+
+Guidelines for developers making pull requests to DiscoPanel.
+
+*This section is a work in progress.*
diff --git a/docs/discopanel/src/content/docs/faq.md b/docs/discopanel/src/content/docs/faq.md
new file mode 100644
index 0000000..b551210
--- /dev/null
+++ b/docs/discopanel/src/content/docs/faq.md
@@ -0,0 +1,8 @@
+---
+title: FAQ
+description: Frequently asked questions.
+---
+
+Answers to the questions that get asked over and over.
+
+*This section is a work in progress.*
diff --git a/docs/discopanel/src/content/docs/getting-started/build-from-source.mdx b/docs/discopanel/src/content/docs/getting-started/build-from-source.mdx
new file mode 100644
index 0000000..056dbb0
--- /dev/null
+++ b/docs/discopanel/src/content/docs/getting-started/build-from-source.mdx
@@ -0,0 +1,57 @@
+---
+title: Building from Source
+description: Build DiscoPanel from source code.
+---
+
+## Prerequisites
+
+DiscoPanel manages Minecraft servers as Docker containers, so Docker is required both at build time and runtime.
+
+- **Go** 1.24+
+- **Node.js** 22+
+- **Docker** (also 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)
+```
+:::
+
+For all available configuration options, see [Configuration](/configuration/).
diff --git a/docs/discopanel/src/content/docs/getting-started/docker-compose.mdx b/docs/discopanel/src/content/docs/getting-started/docker-compose.mdx
new file mode 100644
index 0000000..6eb1789
--- /dev/null
+++ b/docs/discopanel/src/content/docs/getting-started/docker-compose.mdx
@@ -0,0 +1,47 @@
+---
+title: Docker Compose
+description: Deploy DiscoPanel using Docker Compose.
+---
+
+import { Code } from '@astrojs/starlight/components';
+import dockerCompose from '../../../../../../docker-compose.yml?raw';
+
+## Prerequisites
+
+DiscoPanel manages Minecraft servers as Docker containers, so Docker is required.
+
+- [Docker Engine](https://docs.docker.com/engine/install/) with Docker Compose
+
+## Compose file
+
+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.
+
+For all available configuration options, see [Configuration](/configuration/).
diff --git a/docs/discopanel/src/content/docs/getting-started/prebuilt-binaries.mdx b/docs/discopanel/src/content/docs/getting-started/prebuilt-binaries.mdx
new file mode 100644
index 0000000..68360d9
--- /dev/null
+++ b/docs/discopanel/src/content/docs/getting-started/prebuilt-binaries.mdx
@@ -0,0 +1,53 @@
+---
+title: Prebuilt Binaries
+description: Install DiscoPanel from prebuilt release binaries.
+---
+
+## Prerequisites
+
+DiscoPanel manages Minecraft servers as Docker containers, so Docker is required.
+
+- [Docker Engine](https://docs.docker.com/engine/install/)
+
+## Download
+
+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 the archive and run it:
+
+## 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)
+```
+:::
+
+For all available configuration options, see [Configuration](/configuration/).
diff --git a/docs/discopanel/src/content/docs/getting-started/proxmox.mdx b/docs/discopanel/src/content/docs/getting-started/proxmox.mdx
new file mode 100644
index 0000000..e84c18c
--- /dev/null
+++ b/docs/discopanel/src/content/docs/getting-started/proxmox.mdx
@@ -0,0 +1,36 @@
+---
+title: Proxmox LXC
+description: Deploy DiscoPanel in a Proxmox LXC container using the community helper script.
+---
+
+## Prerequisites
+
+- A [Proxmox VE](https://www.proxmox.com/en/proxmox-virtual-environment/overview) host
+
+## Installation
+
+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**.
+
+For all available configuration options, see [Configuration](/configuration/).
diff --git a/docs/discopanel/src/content/docs/guides/authelia.mdx b/docs/discopanel/src/content/docs/guides/authelia.mdx
new file mode 100644
index 0000000..298b0d6
--- /dev/null
+++ b/docs/discopanel/src/content/docs/guides/authelia.mdx
@@ -0,0 +1,89 @@
+---
+title: Authelia (OIDC)
+description: Set up DiscoPanel with Authelia as an OpenID Connect identity provider.
+---
+
+import { Code, Aside } from '@astrojs/starlight/components';
+import composeFile from '../../../../../../oidc/authelia/docker-compose.yaml?raw';
+import configYml from '../../../../../../oidc/authelia/config/configuration.yml?raw';
+import usersYml from '../../../../../../oidc/authelia/config/users_database.yml?raw';
+
+This guide walks through running DiscoPanel with [Authelia](https://www.authelia.com/) as an OIDC identity provider using a ready-made Docker Compose stack. Authelia provides a file-based user store, so no external database is needed.
+
+
+
+## Prerequisites
+
+- Docker and Docker Compose
+
+## Docker Compose
+
+Clone the repo and navigate to the `oidc/authelia/` directory, then start the stack:
+
+```bash
+cd oidc/authelia
+docker compose up -d
+```
+
+
+
+### Key environment variables
+
+| Variable | Purpose |
+|---|---|
+| `DISCOPANEL_AUTH_OIDC_ENABLED` | Enables OIDC authentication |
+| `DISCOPANEL_AUTH_OIDC_ISSUER_URI` | Authelia's OIDC issuer URL — must match the `session.cookies` domain in `configuration.yml` |
+| `DISCOPANEL_AUTH_OIDC_CLIENT_ID` | Must match the `client_id` in `configuration.yml` |
+| `DISCOPANEL_AUTH_OIDC_CLIENT_SECRET` | The **plaintext** secret — the hashed version is stored in `configuration.yml` |
+| `DISCOPANEL_AUTH_OIDC_REDIRECT_URL` | The callback URL — update `localhost:8080` to your public domain |
+| `DISCOPANEL_AUTH_OIDC_ROLE_CLAIM` | Set to `groups` to read Authelia group membership as DiscoPanel roles |
+| `DISCOPANEL_AUTH_OIDC_SKIP_TLS_VERIFY` | Set to `true` because the included TLS certs are self-signed — **remove this when using real certs** |
+
+## Authelia configuration
+
+
+
+### Notable sections
+
+- **`identity_providers.oidc.clients`**: the DiscoPanel OIDC client — `client_secret` is a PBKDF2-SHA512 hash of the plaintext secret
+- **`claims_policies.discopanel`**: puts `groups` directly in the ID token so DiscoPanel can read roles without a separate UserInfo call
+- **`identity_providers.oidc.jwks`**: an RSA key pair used for signing tokens — generate your own for production
+- **`session.cookies.domain`**: set to `traefik.me` for development — change to your actual domain
+
+## User database
+
+Authelia stores users in a YAML file. The default config includes a single admin user.
+
+
+
+### Generating password hashes
+
+To add or change users, generate an Argon2id hash:
+
+```bash
+docker run --rm authelia/authelia:latest \
+ authelia crypto hash generate argon2 \
+ --password 'your-password-here'
+```
+
+## Default credentials
+
+| Service | URL | Username | Password |
+|---|---|---|---|
+| DiscoPanel | [http://localhost:8080](http://localhost:8080) | — | Log in via OIDC |
+| Authelia login portal | [https://authelia.traefik.me:9091](https://authelia.traefik.me:9091) | `admin` | `admin` |
+
+## Production notes
+
+
+
+- **Use real TLS certificates**: replace `tls.crt` and `tls.key`, then remove `DISCOPANEL_AUTH_OIDC_SKIP_TLS_VERIFY`
+- **Change all secrets**: the HMAC secret, session secret, storage encryption key, JWT reset secret, and the OIDC client secret (both the hash in `configuration.yml` and the plaintext in the compose file)
+- **Generate new JWKS keys**: replace the RSA key pair in `configuration.yml`
+- **Update the session domain**: change `traefik.me` to your actual domain
+- **Update redirect URIs**: change `localhost` entries to your actual domain
+- **Disable local auth** (optional): set `DISCOPANEL_AUTH_LOCAL_ENABLED=false` if you want OIDC-only login
diff --git a/docs/discopanel/src/content/docs/guides/keycloak.mdx b/docs/discopanel/src/content/docs/guides/keycloak.mdx
new file mode 100644
index 0000000..a6ac186
--- /dev/null
+++ b/docs/discopanel/src/content/docs/guides/keycloak.mdx
@@ -0,0 +1,69 @@
+---
+title: Keycloak (OIDC)
+description: Set up DiscoPanel with Keycloak as an OpenID Connect identity provider.
+---
+
+import { Code, Aside } from '@astrojs/starlight/components';
+import composeFile from '../../../../../../oidc/keycloak/docker-compose.yaml?raw';
+import realmJson from '../../../../../../oidc/keycloak/config/realm.json?raw';
+
+This guide walks through running DiscoPanel with [Keycloak](https://www.keycloak.org/) as an OIDC identity provider using a ready-made Docker Compose stack. The included realm config pre-creates a client, roles, groups, protocol mappers, and a default admin user so everything works out of the box.
+
+## Prerequisites
+
+- Docker and Docker Compose
+
+## Docker Compose
+
+Clone the repo and navigate to the `oidc/keycloak/` directory, then start the stack:
+
+```bash
+cd oidc/keycloak
+docker compose up -d
+```
+
+Keycloak takes 30–60 seconds to start. DiscoPanel waits for it via a health check.
+
+
+
+### Key environment variables
+
+| Variable | Purpose |
+|---|---|
+| `DISCOPANEL_AUTH_OIDC_ENABLED` | Enables OIDC authentication |
+| `DISCOPANEL_AUTH_OIDC_ISSUER_URI` | Keycloak realm URL — change if hosting on another machine or using a different realm name |
+| `DISCOPANEL_AUTH_OIDC_CLIENT_ID` | Must match the `clientId` in the realm config |
+| `DISCOPANEL_AUTH_OIDC_CLIENT_SECRET` | Must match the `secret` in the realm config — **change this for production** |
+| `DISCOPANEL_AUTH_OIDC_REDIRECT_URL` | The callback URL — update `localhost:8080` to your public domain |
+| `DISCOPANEL_AUTH_OIDC_ROLE_CLAIM` | Set to `groups` to read Keycloak group membership as DiscoPanel roles |
+
+## Realm configuration
+
+The included realm JSON is imported automatically on first start via `--import-realm`. It configures:
+
+- **Client** (`discopanel`): confidential client with standard (authorization code) flow
+- **Roles**: `admin` and `user` realm roles
+- **Groups**: `admin` and `user` groups mapped to their respective roles — new users are added to the `user` group by default
+- **Protocol mappers**: a `groups` mapper that puts group membership into the `groups` claim on all tokens, and a `realm-roles` mapper for the `roles` claim
+- **Default user**: `admin` / `admin` with both `admin` and `user` groups
+
+
+
+## Default credentials
+
+| Service | URL | Username | Password |
+|---|---|---|---|
+| DiscoPanel | [http://localhost:8080](http://localhost:8080) | — | Log in via OIDC |
+| Keycloak Admin Console | [http://localhost:8180/admin](http://localhost:8180/admin) | `admin` | `admin` |
+| Keycloak OIDC user | — | `admin` | `admin` |
+
+## Production notes
+
+
+
+- **Change all secrets**: `DISCOPANEL_AUTH_OIDC_CLIENT_SECRET`, `KC_BOOTSTRAP_ADMIN_PASSWORD`, the client `secret` in realm.json, and the Postgres password
+- **Enable TLS**: put Keycloak behind a reverse proxy with a real certificate and update `DISCOPANEL_AUTH_OIDC_ISSUER_URI` to `https://`
+- **Update redirect URIs**: change `localhost` entries in both the compose file and realm.json to your actual domain
+- **Disable local auth** (optional): set `DISCOPANEL_AUTH_LOCAL_ENABLED=false` if you want OIDC-only login
diff --git a/docs/discopanel/src/content/docs/index.mdx b/docs/discopanel/src/content/docs/index.mdx
new file mode 100644
index 0000000..96f10f1
--- /dev/null
+++ b/docs/discopanel/src/content/docs/index.mdx
@@ -0,0 +1,148 @@
+---
+title: DiscoPanel
+description: Minecraft server management, simplified.
+template: splash
+hero:
+ tagline: A self-hosted Minecraft server + proxy + modpack manager. Docker-powered, easy to deploy, and it actually works.
+ image:
+ html: |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ actions:
+ - text: Get Started
+ link: /introduction/
+ icon: right-arrow
+ variant: primary
+ - text: View on GitHub
+ link: https://github.com/nickheyer/discopanel
+ icon: external
+ variant: minimal
+---
diff --git a/docs/discopanel/src/content/docs/introduction.md b/docs/discopanel/src/content/docs/introduction.md
new file mode 100644
index 0000000..cc99183
--- /dev/null
+++ b/docs/discopanel/src/content/docs/introduction.md
@@ -0,0 +1,8 @@
+---
+title: Introduction
+description: What DiscoPanel is and what it does.
+---
+
+DiscoPanel is a self-hosted, Docker-powered Minecraft server manager. It handles server creation, modpack installation, proxy routing, backups, and multi-user access control through a web interface.
+
+For a full feature overview, see the [project README](https://github.com/nickheyer/discopanel).
diff --git a/docs/discopanel/src/content/docs/troubleshooting.md b/docs/discopanel/src/content/docs/troubleshooting.md
new file mode 100644
index 0000000..a7d9cd4
--- /dev/null
+++ b/docs/discopanel/src/content/docs/troubleshooting.md
@@ -0,0 +1,19 @@
+---
+title: Troubleshooting
+description: Common issues and how to report bugs.
+---
+
+Common errors, known quirks (like CurseForge API keys randomly invalidating), and steps to try before reporting an issue.
+
+### Reporting Issues
+
+Before opening a GitHub issue:
+
+1. Check this page and the [FAQ](/faq/) first
+2. Try the latest version of DiscoPanel
+3. Include your Docker/compose version, DiscoPanel version, and relevant logs
+4. Open an issue at [github.com/nickheyer/discopanel/issues](https://github.com/nickheyer/discopanel/issues)
+
+For quicker help, ask in the [Discord](https://discord.gg/6Z9yKTbsrP).
+
+*This section is a work in progress.*
diff --git a/docs/discopanel/tsconfig.json b/docs/discopanel/tsconfig.json
new file mode 100644
index 0000000..425deb5
--- /dev/null
+++ b/docs/discopanel/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "./node_modules/astro/tsconfigs/strict",
+ "include": [".astro/types.d.ts", "**/*"],
+ "exclude": ["dist"]
+}
diff --git a/go.mod b/go.mod
index 23ebd1d..ac37945 100644
--- a/go.mod
+++ b/go.mod
@@ -1,25 +1,65 @@
module github.com/nickheyer/discopanel
-go 1.24.5
+go 1.24.6
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/go-gormigrate/gormigrate/v2 v2.1.5
github.com/golang-jwt/jwt/v5 v5.3.0
+ github.com/google/gnostic v0.7.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.1
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/google/gnostic-models v0.7.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
+ go.yaml.in/yaml/v3 v3.0.4 // 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 (
@@ -39,7 +79,7 @@ require (
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
- github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
+ github.com/go-viper/mapstructure/v2 v2.2.1
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -75,9 +115,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..5fec6c5 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,34 @@ 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-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8=
+github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M=
+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=
@@ -97,31 +157,57 @@ github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/gnostic v0.7.1 h1:t5Kc7j/8kYr8t2u11rykRrPPovlEMG4+xdc/SpekATs=
+github.com/google/gnostic v0.7.1/go.mod h1:KSw6sxnxEBFM8jLPfJd46xZP+yQcfE8XkiqfZx5zR28=
+github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
+github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
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 +222,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 +254,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 +270,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 +320,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=
@@ -248,6 +360,8 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -255,8 +369,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 +389,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 +409,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 +428,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 +461,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 +484,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 +524,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 +564,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 +620,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 +648,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..ff2585e
--- /dev/null
+++ b/internal/auth/manager.go
@@ -0,0 +1,475 @@
+package auth
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "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)")
+ ErrAPITokenExpired = errors.New("api token has expired")
+ ErrAPITokenNotFound = errors.New("api token not found")
+)
+
+// 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
+}
+
+// Creates a new API token for a user. Plaintext is returned, SHA-256 hash is stored
+func (m *Manager) GenerateAPIToken(ctx context.Context, userID, name string, expiresInDays *int32) (string, *db.APIToken, error) {
+ // Generate 32 random bytes
+ raw := make([]byte, 32)
+ if _, err := rand.Read(raw); err != nil {
+ return "", nil, fmt.Errorf("failed to generate token: %w", err)
+ }
+
+ plaintext := "dp_" + base64.RawURLEncoding.EncodeToString(raw)
+
+ // SHA-256 hash for storage
+ hash := sha256.Sum256([]byte(plaintext))
+ hashHex := hex.EncodeToString(hash[:])
+
+ var expiresAt *time.Time
+ if expiresInDays != nil && *expiresInDays > 0 {
+ t := time.Now().Add(time.Duration(*expiresInDays) * 24 * time.Hour)
+ expiresAt = &t
+ }
+
+ token := &db.APIToken{
+ ID: uuid.New().String(),
+ UserID: userID,
+ Name: name,
+ TokenHash: hashHex,
+ ExpiresAt: expiresAt,
+ }
+
+ if err := m.store.CreateAPIToken(ctx, token); err != nil {
+ return "", nil, fmt.Errorf("failed to store api token: %w", err)
+ }
+
+ return plaintext, token, nil
+}
+
+// Validates a raw API token (dp_...) and returns the authenticated user.
+func (m *Manager) ValidateAPIToken(ctx context.Context, rawToken string) (*AuthenticatedUser, error) {
+ if !strings.HasPrefix(rawToken, "dp_") {
+ return nil, ErrInvalidToken
+ }
+
+ // Hash the incoming token
+ hash := sha256.Sum256([]byte(rawToken))
+ hashHex := hex.EncodeToString(hash[:])
+
+ // Look up by hash
+ apiToken, err := m.store.GetAPITokenByHash(ctx, hashHex)
+ if err != nil {
+ return nil, ErrAPITokenNotFound
+ }
+
+ // Check expiry
+ if apiToken.ExpiresAt != nil && apiToken.ExpiresAt.Before(time.Now()) {
+ return nil, ErrAPITokenExpired
+ }
+
+ // Resolve user
+ user, err := m.store.GetUser(ctx, apiToken.UserID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get token user: %w", err)
+ }
+
+ if !user.IsActive {
+ return nil, ErrUserNotActive
+ }
+
+ // Get roles
+ roleNames, err := m.store.GetUserRoleNames(ctx, user.ID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get user roles: %w", err)
+ }
+
+ // Background-update last_used_at
+ go func() {
+ _ = m.store.UpdateAPITokenLastUsed(context.Background(), apiToken.ID)
+ }()
+
+ 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) 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..4e57610 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -6,7 +6,6 @@ import (
"slices"
"strings"
- "github.com/nickheyer/discopanel/internal/db"
"github.com/spf13/viper"
)
@@ -20,6 +19,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 {
@@ -31,13 +56,6 @@ type ServerConfig struct {
UserAgent string `mapstructure:"user_agent" json:"user_agent"`
}
-type DatabaseConfig struct {
- Path string `mapstructure:"path" json:"path"`
- MaxConnections int `mapstructure:"max_connections" json:"max_connections"`
- MaxIdleConns int `mapstructure:"max_idle_conns" json:"max_idle_conns"`
- ConnMaxLifetime int `mapstructure:"conn_max_lifetime" json:"conn_max_lifetime"`
-}
-
type DockerConfig struct {
SyncInterval int `mapstructure:"sync_interval" json:"sync_interval"`
Host string `mapstructure:"host" json:"host"`
@@ -68,9 +86,17 @@ type ModuleConfig struct {
PortRangeMax int `mapstructure:"port_range_max" json:"port_range_max"`
}
+type DatabaseConfig struct {
+ Path string `mapstructure:"path" json:"path"`
+ MaxConnections int `mapstructure:"max_connections" json:"max_connections"`
+ MaxIdleConns int `mapstructure:"max_idle_conns" json:"max_idle_conns"`
+ ConnMaxLifetime int `mapstructure:"conn_max_lifetime" json:"conn_max_lifetime"`
+ AutoMigrate bool `mapstructure:"auto_migrate" json:"auto_migrate"`
+}
+
type MinecraftConfig struct {
- ResetGlobal bool `mapstructure:"reset_global" json:"reset_global"`
- GlobalConfig db.ServerConfig `mapstructure:"global_config" json:"global_config"`
+ ResetGlobal bool `mapstructure:"reset_global" json:"reset_global"`
+ GlobalConfig map[string]any `mapstructure:"global_config" json:"global_config"`
}
type LoggingConfig struct {
@@ -148,6 +174,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("database.max_connections", 25)
v.SetDefault("database.max_idle_conns", 5)
v.SetDefault("database.conn_max_lifetime", 300)
+ v.SetDefault("database.auto_migrate", true)
// Docker defaults
v.SetDefault("docker.sync_interval", 5)
@@ -189,6 +216,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"})
+ 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
@@ -243,8 +285,3 @@ func validateConfig(cfg *Config) error {
return nil
}
-
-// LoadGlobalServerConfig returns the global ServerConfig defaults from the config file
-func LoadGlobalServerConfig(cfg *Config) db.ServerConfig {
- return cfg.Minecraft.GlobalConfig
-}
diff --git a/internal/db/migrations.go b/internal/db/migrations.go
new file mode 100644
index 0000000..ef393d1
--- /dev/null
+++ b/internal/db/migrations.go
@@ -0,0 +1,142 @@
+package db
+
+import (
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/go-gormigrate/gormigrate/v2"
+ "gorm.io/gorm"
+)
+
+func allModels() []any {
+ return []any{
+ &Server{},
+ &ServerConfig{},
+ &Mod{},
+ &IndexedModpack{},
+ &IndexedModpackFile{},
+ &ModpackFavorite{},
+ &ProxyConfig{},
+ &ProxyListener{},
+ &User{},
+ &Role{},
+ &UserRole{},
+ &Session{},
+ &APIToken{},
+ &RegistrationInvite{},
+ &ScheduledTask{},
+ &TaskExecution{},
+ &ModuleTemplate{},
+ &Module{},
+ &SystemSetting{},
+ }
+}
+
+func (s *Store) Migrate() error {
+ if err := s.backupDB(); err != nil {
+ return fmt.Errorf("pre-migration backup failed: %w", err)
+ }
+
+ m := gormigrate.New(s.db, &gormigrate.Options{
+ TableName: "migrations",
+ IDColumnName: "id",
+ IDColumnSize: 200,
+ UseTransaction: true,
+ ValidateUnknownMigrations: false,
+ }, migrations())
+
+ m.InitSchema(func(db *gorm.DB) error {
+ return db.AutoMigrate(allModels()...)
+ })
+
+ if err := m.Migrate(); err != nil {
+ return fmt.Errorf("migration failed: %w", err)
+ }
+
+ if err := seeds(s); err != nil {
+ return fmt.Errorf("seed failed: %w", err)
+ }
+
+ log.Println("[migrate] Migration complete")
+ return nil
+}
+
+func seeds(s *Store) error {
+ for _, seed := range []func() error{
+ s.SeedSystemRoles,
+ s.SeedGlobalSettings,
+ } {
+ if err := seed(); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func migrations() []*gormigrate.Migration {
+ return []*gormigrate.Migration{
+ {
+ ID: "20260226_001_backfill_user_roles",
+ Migrate: func(tx *gorm.DB) error {
+ // Find users that have no entry in user_roles
+ var usersWithoutRoles []User
+ if err := tx.Where("id NOT IN (SELECT DISTINCT user_id FROM user_roles)").
+ Order("created_at ASC").
+ Find(&usersWithoutRoles).Error; err != nil {
+ return err
+ }
+
+ if len(usersWithoutRoles) == 0 {
+ return nil
+ }
+
+ var adminCount int64
+ tx.Model(&UserRole{}).Where("role_name = ?", "admin").Count(&adminCount)
+
+ for i, user := range usersWithoutRoles {
+ roleName := "user"
+ if i == 0 && adminCount == 0 {
+ roleName = "admin"
+ }
+ ur := UserRole{
+ ID: user.ID + "-" + roleName,
+ UserID: user.ID,
+ RoleName: roleName,
+ Source: "migration",
+ }
+ if err := tx.Create(&ur).Error; err != nil {
+ return fmt.Errorf("failed to assign role %s to user %s: %w", roleName, user.Username, err)
+ }
+ log.Printf("[migrate] Assigned role '%s' to user '%s'", roleName, user.Username)
+ }
+
+ return nil
+ },
+ Rollback: func(tx *gorm.DB) error {
+ return tx.Where("source = ?", "migration").Delete(&UserRole{}).Error
+ },
+ },
+ }
+}
+
+func (s *Store) backupDB() error {
+ if s.cfg.Database.Path == "" || s.cfg.Database.Path == ":memory:" {
+ return nil
+ }
+
+ var count int
+ row := s.db.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Row()
+ if err := row.Scan(&count); err != nil || count == 0 {
+ return nil
+ }
+
+ backupPath := s.cfg.Database.Path + ".pre-migrate.bak"
+ os.Remove(backupPath)
+ if err := s.db.Exec("VACUUM INTO ?", backupPath).Error; err != nil {
+ return fmt.Errorf("VACUUM INTO %s: %w", backupPath, err)
+ }
+
+ log.Printf("[migrate] Database backed up to %s", backupPath)
+ return nil
+}
diff --git a/internal/db/models.go b/internal/db/models.go
index f4191ae..4862fab 100644
--- a/internal/db/models.go
+++ b/internal/db/models.go
@@ -386,40 +386,71 @@ 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
-)
+// 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"`
- 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"`
+}
+
+// APIToken represents a long-lived API token for programmatic access
+type APIToken struct {
+ ID string `json:"id" gorm:"primaryKey"`
+ UserID string `json:"user_id" gorm:"not null;index;column:user_id"`
+ Name string `json:"name" gorm:"not null"`
+ TokenHash string `json:"-" gorm:"not null;uniqueIndex;column:token_hash"`
+ ExpiresAt *time.Time `json:"expires_at" gorm:"column:expires_at"`
+ LastUsedAt *time.Time `json:"last_used_at" gorm:"column:last_used_at"`
+ CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
+ User *User `json:"-" gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
}
// Session represents an active user session
@@ -428,8 +459,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..626214b 100644
--- a/internal/db/store.go
+++ b/internal/db/store.go
@@ -6,24 +6,21 @@ import (
"reflect"
"time"
+ "github.com/go-viper/mapstructure/v2"
"github.com/google/uuid"
+ "github.com/nickheyer/discopanel/internal/config"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
-type DBConfig struct {
- MaxOpenConns int
- MaxIdleConns int
- ConnMaxLifetime time.Duration
-}
-
type Store struct {
- db *gorm.DB
+ db *gorm.DB
+ cfg *config.Config
}
-func NewSQLiteStore(dbPath string, config ...DBConfig) (*Store, error) {
- db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
+func NewSQLiteStore(cfg *config.Config) (*Store, error) {
+ db, err := gorm.Open(sqlite.Open(cfg.Database.Path), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
NowFunc: func() time.Time {
return time.Now().UTC()
@@ -33,35 +30,36 @@ func NewSQLiteStore(dbPath string, config ...DBConfig) (*Store, error) {
return nil, fmt.Errorf("failed to open database: %w", err)
}
- // Get underlying SQL database to configure connection pool
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("failed to get database handle: %w", err)
}
- // Apply connection pool configuration if provided
- if len(config) > 0 {
- cfg := config[0]
- if cfg.MaxOpenConns > 0 {
- sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
- }
- if cfg.MaxIdleConns > 0 {
- sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
- }
- if cfg.ConnMaxLifetime > 0 {
- sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime)
- }
+ if cfg.Database.MaxConnections > 0 {
+ sqlDB.SetMaxOpenConns(cfg.Database.MaxConnections)
+ }
+ if cfg.Database.MaxIdleConns > 0 {
+ sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
+ }
+ if cfg.Database.ConnMaxLifetime > 0 {
+ sqlDB.SetConnMaxLifetime(time.Duration(cfg.Database.ConnMaxLifetime) * time.Second)
}
- store := &Store{db: db}
+ store := &Store{db: db, cfg: cfg}
- if err := store.Migrate(); err != nil {
- return nil, fmt.Errorf("failed to migrate database: %w", err)
+ if cfg.Database.AutoMigrate {
+ if err := store.Migrate(); err != nil {
+ return nil, fmt.Errorf("failed to migrate database: %w", err)
+ }
}
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 {
@@ -70,40 +68,6 @@ func (s *Store) Close() error {
return sqlDB.Close()
}
-func (s *Store) Migrate() error {
- // Auto-migrate all models
- err := s.db.AutoMigrate(
- &Server{},
- &ServerConfig{},
- &Mod{},
- &IndexedModpack{},
- &IndexedModpackFile{},
- &ModpackFavorite{},
- &ProxyConfig{},
- &ProxyListener{},
- &User{},
- &AuthConfig{},
- &Session{},
- &ScheduledTask{},
- &TaskExecution{},
- &ModuleTemplate{},
- &Module{},
- )
- if err != nil {
- return fmt.Errorf("failed to auto-migrate: %w", err)
- }
-
- // Create indexes
- if err := s.db.Exec("CREATE INDEX IF NOT EXISTS idx_servers_port ON servers(port)").Error; err != nil {
- return err
- }
- if err := s.db.Exec("CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)").Error; err != nil {
- return err
- }
-
- return nil
-}
-
// Server operations
func (s *Store) CreateServer(ctx context.Context, server *Server) error {
err := s.db.WithContext(ctx).Create(server).Error
@@ -236,7 +200,6 @@ func (s *Store) SyncServerConfigWithServer(ctx context.Context, server *Server)
}
}
- // Helper functions
stringPtr := func(s string) *string { return &s }
intPtr := func(i int) *int { return &i }
config.Type = stringPtr(string(server.ModLoader))
@@ -248,7 +211,6 @@ func (s *Store) SyncServerConfigWithServer(ctx context.Context, server *Server)
}
func (s *Store) CreateDefaultServerConfig(serverID string) *ServerConfig {
- // Helper functions to create pointers
boolPtr := func(b bool) *bool { return &b }
stringPtr := func(s string) *string { return &s }
intPtr := func(i int) *int { return &i }
@@ -580,6 +542,24 @@ func (s *Store) UpdateGlobalSettings(ctx context.Context, config *ServerConfig)
return s.db.WithContext(ctx).Save(config).Error
}
+func (s *Store) SeedGlobalSettings() error {
+ ctx := context.Background()
+ _, isNew, err := s.GetGlobalSettings(ctx)
+ if err != nil {
+ return err
+ }
+ if isNew || s.cfg.Minecraft.ResetGlobal {
+ gc := s.CreateDefaultServerConfig(GlobalSettingsID)
+ if len(s.cfg.Minecraft.GlobalConfig) > 0 {
+ mapstructure.WeakDecode(s.cfg.Minecraft.GlobalConfig, gc)
+ gc.ID = GlobalSettingsID + "-config"
+ gc.ServerID = GlobalSettingsID
+ }
+ return s.UpdateGlobalSettings(ctx, gc)
+ }
+ return nil
+}
+
// ProxyConfig operations
func (s *Store) GetProxyConfig(ctx context.Context) (*ProxyConfig, bool, error) {
var config ProxyConfig
@@ -727,9 +707,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 +719,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 +743,15 @@ 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(&APIToken{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("user_id = ?", id).Delete(&UserRole{}).Error; err != nil {
+ return err
+ }
return tx.Delete(&User{}, "id = ?", id).Error
})
}
@@ -778,31 +762,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 +890,119 @@ 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
+}
+
+// APIToken operations
+func (s *Store) CreateAPIToken(ctx context.Context, token *APIToken) error {
+ if token.ID == "" {
+ token.ID = uuid.New().String()
+ }
+ return s.db.WithContext(ctx).Create(token).Error
+}
+
+func (s *Store) GetAPITokenByHash(ctx context.Context, hash string) (*APIToken, error) {
+ var token APIToken
+ err := s.db.WithContext(ctx).Where("token_hash = ?", hash).First(&token).Error
+ if err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return nil, fmt.Errorf("api token not found")
+ }
+ return nil, err
+ }
+ return &token, nil
+}
+
+func (s *Store) ListAPITokensByUser(ctx context.Context, userID string) ([]APIToken, error) {
+ var tokens []APIToken
+ err := s.db.WithContext(ctx).Where("user_id = ?", userID).Order("created_at DESC").Find(&tokens).Error
+ return tokens, err
+}
+
+func (s *Store) DeleteAPIToken(ctx context.Context, id, userID string) error {
+ result := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, userID).Delete(&APIToken{})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return fmt.Errorf("api token not found")
+ }
+ return nil
+}
+
+func (s *Store) UpdateAPITokenLastUsed(ctx context.Context, id string) error {
+ return s.db.WithContext(ctx).Model(&APIToken{}).Where("id = ?", id).Update("last_used_at", time.Now()).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) {
+ 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/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..8783e9b 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: !vol.ReadOnly},
})
}
diff --git a/internal/indexers/errors.go b/internal/indexers/errors.go
new file mode 100644
index 0000000..7cb2818
--- /dev/null
+++ b/internal/indexers/errors.go
@@ -0,0 +1,147 @@
+package indexers
+
+import (
+ "errors"
+ "fmt"
+ "net"
+)
+
+// indexer errors
+type ErrorKind int
+
+const (
+ ErrAuth ErrorKind = iota // 401/403 or missing API key
+ ErrRateLimit // 429
+ ErrNotFound // 404
+ ErrNetwork // DNS failure, timeout, connection refused
+ ErrAPI // Other non-2xx status codes
+ ErrDecode // JSON decode failures
+)
+
+func (k ErrorKind) String() string {
+ switch k {
+ case ErrAuth:
+ return "authentication error"
+ case ErrRateLimit:
+ return "rate limited"
+ case ErrNotFound:
+ return "not found"
+ case ErrNetwork:
+ return "network error"
+ case ErrAPI:
+ return "API error"
+ case ErrDecode:
+ return "decode error"
+ default:
+ return "unknown error"
+ }
+}
+
+type IndexerError struct {
+ Kind ErrorKind
+ Indexer string
+ StatusCode int
+ URL string
+ Body string
+ Err error
+}
+
+func (e *IndexerError) Error() string {
+ base := fmt.Sprintf("%s: %s", e.Indexer, e.Kind)
+
+ if e.StatusCode != 0 {
+ base = fmt.Sprintf("%s (status %d)", base, e.StatusCode)
+ }
+ if e.URL != "" {
+ base = fmt.Sprintf("%s url=%s", base, e.URL)
+ }
+ if e.Err != nil {
+ base = fmt.Sprintf("%s: %v", base, e.Err)
+ }
+ if e.Body != "" {
+ base = fmt.Sprintf("%s body=%s", base, e.Body)
+ }
+ return base
+}
+
+func (e *IndexerError) Unwrap() error {
+ return e.Err
+}
+
+// It automatically classifies 401/403 as Auth, 404 as NotFound, 429 as RateLimit.
+func NewAPIError(indexer string, statusCode int, url string, body string) *IndexerError {
+ kind := ErrAPI
+ switch {
+ case statusCode == 401 || statusCode == 403:
+ kind = ErrAuth
+ case statusCode == 404:
+ kind = ErrNotFound
+ case statusCode == 429:
+ kind = ErrRateLimit
+ }
+ return &IndexerError{
+ Kind: kind,
+ Indexer: indexer,
+ StatusCode: statusCode,
+ URL: url,
+ Body: body,
+ }
+}
+
+// IndexerError for network-level failures like dns
+func NewNetworkError(indexer string, url string, err error) *IndexerError {
+ wrapped := err
+ var dnsErr *net.DNSError
+ if errors.As(err, &dnsErr) {
+ wrapped = fmt.Errorf("DNS lookup failed for %s: %w", dnsErr.Name, err)
+ }
+ return &IndexerError{
+ Kind: ErrNetwork,
+ Indexer: indexer,
+ URL: url,
+ Err: wrapped,
+ }
+}
+
+// JSON decode failures.
+func NewDecodeError(indexer string, url string, err error) *IndexerError {
+ return &IndexerError{
+ Kind: ErrDecode,
+ Indexer: indexer,
+ URL: url,
+ Err: err,
+ }
+}
+
+// Missing API key configuration
+func NewAuthConfigError(indexer string, msg string) *IndexerError {
+ return &IndexerError{
+ Kind: ErrAuth,
+ Indexer: indexer,
+ Err: errors.New(msg),
+ }
+}
+
+// Reports whether the error is a rate-limit error
+func IsRateLimit(err error) bool {
+ var ie *IndexerError
+ return errors.As(err, &ie) && ie.Kind == ErrRateLimit
+}
+
+// Reports whether the error is an auth error
+func IsAuthError(err error) bool {
+ var ie *IndexerError
+ return errors.As(err, &ie) && ie.Kind == ErrAuth
+}
+
+// Reports whether the error is a not-found error
+func IsNotFound(err error) bool {
+ var ie *IndexerError
+ return errors.As(err, &ie) && ie.Kind == ErrNotFound
+}
+
+// Reports whether the error is a network error
+func IsNetworkError(err error) bool {
+ var ie *IndexerError
+ return errors.As(err, &ie) && ie.Kind == ErrNetwork
+}
diff --git a/internal/indexers/fuego/adapter.go b/internal/indexers/fuego/adapter.go
index 980aada..a820155 100644
--- a/internal/indexers/fuego/adapter.go
+++ b/internal/indexers/fuego/adapter.go
@@ -8,8 +8,15 @@ import (
"github.com/nickheyer/discopanel/internal/config"
"github.com/nickheyer/discopanel/internal/indexers"
+ "github.com/nickheyer/discopanel/pkg/utils"
)
+func init() {
+ indexers.RegisterIndexer("fuego", func(apiKey string, cfg *config.Config) indexers.ModpackIndexer {
+ return NewIndexer(apiKey, cfg)
+ })
+}
+
// Implements ModpackIndexer
var _ indexers.ModpackIndexer = (*FuegoIndexer)(nil)
@@ -141,8 +148,8 @@ func (f *FuegoIndexer) convertModpack(fm Modpack) indexers.Modpack {
}
// Deduplicate
- gameVersions = deduplicateStrings(gameVersions)
- modLoaders = deduplicateStrings(modLoaders)
+ gameVersions = utils.DeduplicateStrings(gameVersions)
+ modLoaders = utils.DeduplicateStrings(modLoaders)
logoURL := ""
if fm.Logo.ThumbnailURL != "" {
@@ -210,17 +217,3 @@ func (f *FuegoIndexer) convertFile(file File, modpackID string) indexers.Modpack
ServerPackFileID: &serverPackID,
}
}
-
-func deduplicateStrings(strings []string) []string {
- seen := make(map[string]bool)
- result := []string{}
-
- for _, str := range strings {
- if !seen[str] {
- seen[str] = true
- result = append(result, str)
- }
- }
-
- return result
-}
diff --git a/internal/indexers/fuego/fuego.go b/internal/indexers/fuego/fuego.go
index 5bd7624..1a1762b 100644
--- a/internal/indexers/fuego/fuego.go
+++ b/internal/indexers/fuego/fuego.go
@@ -2,15 +2,13 @@ package fuego
import (
"context"
- "encoding/json"
"fmt"
- "io"
- "net/http"
"net/url"
"strconv"
"time"
"github.com/nickheyer/discopanel/internal/config"
+ "github.com/nickheyer/discopanel/internal/indexers"
)
const (
@@ -20,18 +18,16 @@ const (
)
type Client struct {
- apiKey string
- config *config.Config
- httpClient *http.Client
+ apiKey string
+ http *indexers.HTTPClient
}
func NewClient(apiKey string, cfg *config.Config) *Client {
return &Client{
apiKey: apiKey,
- config: cfg,
- httpClient: &http.Client{
- Timeout: 30 * time.Second,
- },
+ http: indexers.NewHTTPClient("fuego", cfg.Server.UserAgent, map[string]string{
+ "x-api-key": apiKey,
+ }),
}
}
@@ -171,7 +167,7 @@ const (
func (c *Client) SearchModpacks(ctx context.Context, query string, gameVersion string, modLoader ModLoaderType, index, pageSize int) (*SearchModsResponse, error) {
if c.apiKey == "" {
- return nil, fmt.Errorf("fuego API key not configured")
+ return nil, indexers.NewAuthConfigError("fuego", "API key not configured")
}
params := url.Values{}
@@ -194,27 +190,8 @@ func (c *Client) SearchModpacks(ctx context.Context, query string, gameVersion s
params.Set("modLoaderType", strconv.Itoa(int(modLoader)))
}
- req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/mods/search?%s", BaseURL, params.Encode()), nil)
- if err != nil {
- return nil, err
- }
-
- req.Header.Set("x-api-key", c.apiKey)
- req.Header.Set("Accept", "application/json")
- req.Header.Set("User-Agent", c.config.Server.UserAgent)
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return nil, c.formatError(req, resp)
- }
-
var result SearchModsResponse
- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ if err := c.http.DoJSON(ctx, fmt.Sprintf("%s/mods/search?%s", BaseURL, params.Encode()), &result); err != nil {
return nil, err
}
@@ -223,32 +200,13 @@ func (c *Client) SearchModpacks(ctx context.Context, query string, gameVersion s
func (c *Client) GetModpackFiles(ctx context.Context, modID int) ([]File, error) {
if c.apiKey == "" {
- return nil, fmt.Errorf("fuego API key not configured")
- }
-
- req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/mods/%d/files", BaseURL, modID), nil)
- if err != nil {
- return nil, err
- }
-
- req.Header.Set("x-api-key", c.apiKey)
- req.Header.Set("Accept", "application/json")
- req.Header.Set("User-Agent", c.config.Server.UserAgent)
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return nil, c.formatError(req, resp)
+ return nil, indexers.NewAuthConfigError("fuego", "API key not configured")
}
var result struct {
Data []File `json:"data"`
}
- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ if err := c.http.DoJSON(ctx, fmt.Sprintf("%s/mods/%d/files", BaseURL, modID), &result); err != nil {
return nil, err
}
@@ -257,43 +215,15 @@ func (c *Client) GetModpackFiles(ctx context.Context, modID int) ([]File, error)
func (c *Client) GetModpack(ctx context.Context, modID int) (*Modpack, error) {
if c.apiKey == "" {
- return nil, fmt.Errorf("fuego API key not configured")
- }
-
- req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/mods/%d", BaseURL, modID), nil)
- if err != nil {
- return nil, err
- }
-
- req.Header.Set("x-api-key", c.apiKey)
- req.Header.Set("Accept", "application/json")
- req.Header.Set("User-Agent", c.config.Server.UserAgent)
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return nil, c.formatError(req, resp)
+ return nil, indexers.NewAuthConfigError("fuego", "API key not configured")
}
var result struct {
Data Modpack `json:"data"`
}
- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ if err := c.http.DoJSON(ctx, fmt.Sprintf("%s/mods/%d", BaseURL, modID), &result); err != nil {
return nil, err
}
return &result.Data, nil
}
-
-func (c *Client) formatError(req *http.Request, resp *http.Response) error {
- bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
- body := string(bodyBytes)
- if body != "" {
- return fmt.Errorf("fuego API error: %s (url=%s body=%s)", resp.Status, req.URL.String(), body)
- }
- return fmt.Errorf("fuego API error: %s (url=%s)", resp.Status, req.URL.String())
-}
diff --git a/internal/indexers/httpclient.go b/internal/indexers/httpclient.go
new file mode 100644
index 0000000..5a4f05d
--- /dev/null
+++ b/internal/indexers/httpclient.go
@@ -0,0 +1,63 @@
+package indexers
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "time"
+)
+
+// HTTPClient wraps http.Client with common indexer request logic.
+type HTTPClient struct {
+ client *http.Client
+ userAgent string
+ indexer string
+ extraHeaders map[string]string
+}
+
+// NewHTTPClient creates a shared HTTP client for an indexer.
+func NewHTTPClient(indexer string, userAgent string, extraHeaders map[string]string) *HTTPClient {
+ return &HTTPClient{
+ client: &http.Client{
+ Timeout: 30 * time.Second,
+ },
+ userAgent: userAgent,
+ indexer: indexer,
+ extraHeaders: extraHeaders,
+ }
+}
+
+// DoJSON performs a GET request, checks the status, and JSON-decodes into dest.
+// It returns structured IndexerErrors for all failure modes.
+func (h *HTTPClient) DoJSON(ctx context.Context, url string, dest any) error {
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return &IndexerError{Kind: ErrNetwork, Indexer: h.indexer, URL: url, Err: err}
+ }
+
+ req.Header.Set("Accept", "application/json")
+ if h.userAgent != "" {
+ req.Header.Set("User-Agent", h.userAgent)
+ }
+ for k, v := range h.extraHeaders {
+ req.Header.Set(k, v)
+ }
+
+ resp, err := h.client.Do(req)
+ if err != nil {
+ return NewNetworkError(h.indexer, url, err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ return NewAPIError(h.indexer, resp.StatusCode, url, string(bodyBytes))
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(dest); err != nil {
+ return NewDecodeError(h.indexer, url, err)
+ }
+
+ return nil
+}
diff --git a/internal/indexers/indexer.go b/internal/indexers/indexer.go
index 9853f65..2bad4fb 100644
--- a/internal/indexers/indexer.go
+++ b/internal/indexers/indexer.go
@@ -2,7 +2,11 @@ package indexers
import (
"context"
+ "fmt"
+ "sync"
"time"
+
+ "github.com/nickheyer/discopanel/internal/config"
)
// ModpackIndexer defines the interface for modpack indexing services
@@ -65,3 +69,30 @@ type ModpackFile struct {
SortIndex int `json:"sort_index"`
VersionNumber string `json:"version_number"` // Human-readable version for Modrinth
}
+
+// IndexerFactory creates a ModpackIndexer from an API key and config.
+type IndexerFactory func(apiKey string, cfg *config.Config) ModpackIndexer
+
+var (
+ registryMu sync.RWMutex
+ registry = make(map[string]IndexerFactory)
+)
+
+// RegisterIndexer registers an IndexerFactory under the given name.
+// Typically called from an indexer package's init() function.
+func RegisterIndexer(name string, factory IndexerFactory) {
+ registryMu.Lock()
+ defer registryMu.Unlock()
+ registry[name] = factory
+}
+
+// NewIndexer creates a ModpackIndexer by name using the factory registry.
+func NewIndexer(name string, apiKey string, cfg *config.Config) (ModpackIndexer, error) {
+ registryMu.RLock()
+ factory, ok := registry[name]
+ registryMu.RUnlock()
+ if !ok {
+ return nil, fmt.Errorf("unknown indexer: %s", name)
+ }
+ return factory(apiKey, cfg), nil
+}
diff --git a/internal/indexers/modrinth/adapter.go b/internal/indexers/modrinth/adapter.go
index 51a241e..94d40e0 100644
--- a/internal/indexers/modrinth/adapter.go
+++ b/internal/indexers/modrinth/adapter.go
@@ -10,6 +10,12 @@ import (
"github.com/nickheyer/discopanel/internal/indexers"
)
+func init() {
+ indexers.RegisterIndexer("modrinth", func(_ string, cfg *config.Config) indexers.ModpackIndexer {
+ return NewIndexer(cfg)
+ })
+}
+
// Implements ModpackIndexer
var _ indexers.ModpackIndexer = (*ModrinthIndexer)(nil)
diff --git a/internal/indexers/modrinth/client.go b/internal/indexers/modrinth/client.go
index 3763d46..e4ae42b 100644
--- a/internal/indexers/modrinth/client.go
+++ b/internal/indexers/modrinth/client.go
@@ -4,14 +4,12 @@ import (
"context"
"encoding/json"
"fmt"
- "io"
- "net/http"
"net/url"
"strconv"
"strings"
- "time"
"github.com/nickheyer/discopanel/internal/config"
+ "github.com/nickheyer/discopanel/internal/indexers"
)
const (
@@ -19,16 +17,12 @@ const (
)
type Client struct {
- config *config.Config
- httpClient *http.Client
+ http *indexers.HTTPClient
}
func NewClient(cfg *config.Config) *Client {
return &Client{
- config: cfg,
- httpClient: &http.Client{
- Timeout: 30 * time.Second,
- },
+ http: indexers.NewHTTPClient("modrinth", cfg.Server.UserAgent, nil),
}
}
@@ -169,17 +163,14 @@ func (c *Client) SearchModpacks(ctx context.Context, query string, gameVersion s
}
if modLoader != "" {
- // Modrinth uses categories for loaders
facets = append(facets, []string{fmt.Sprintf("categories:%s", strings.ToLower(modLoader))})
}
- // Convert facets to JSON string
facetsJSON, err := json.Marshal(facets)
if err != nil {
return nil, fmt.Errorf("failed to marshal facets: %w", err)
}
- // Build URL with query parameters
params := url.Values{}
if query != "" {
params.Set("query", query)
@@ -189,33 +180,9 @@ func (c *Client) SearchModpacks(ctx context.Context, query string, gameVersion s
params.Set("offset", strconv.Itoa(offset))
params.Set("limit", strconv.Itoa(limit))
- reqURL := fmt.Sprintf("%s/search?%s", BaseURL, params.Encode())
-
- req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("User-Agent", c.config.Server.UserAgent)
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to execute request: %w", err)
- }
- defer func() {
- err := resp.Body.Close()
- if err != nil {
- fmt.Printf("failed to close response body: %v", err)
- }
- }()
-
- if resp.StatusCode != http.StatusOK {
- return nil, c.formatError(req, resp)
- }
-
var searchResp SearchResponse
- if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
+ if err := c.http.DoJSON(ctx, fmt.Sprintf("%s/search?%s", BaseURL, params.Encode()), &searchResp); err != nil {
+ return nil, err
}
return &searchResp, nil
@@ -223,33 +190,9 @@ func (c *Client) SearchModpacks(ctx context.Context, query string, gameVersion s
// GetModpack retrieves detailed information about a specific modpack
func (c *Client) GetModpack(ctx context.Context, modpackID string) (*ProjectDetails, error) {
- reqURL := fmt.Sprintf("%s/project/%s", BaseURL, modpackID)
-
- req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("User-Agent", c.config.Server.UserAgent)
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to execute request: %w", err)
- }
- defer func() {
- err := resp.Body.Close()
- if err != nil {
- fmt.Printf("failed to close response body: %v", err)
- }
- }()
-
- if resp.StatusCode != http.StatusOK {
- return nil, c.formatError(req, resp)
- }
-
var project ProjectDetails
- if err := json.NewDecoder(resp.Body).Decode(&project); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
+ if err := c.http.DoJSON(ctx, fmt.Sprintf("%s/project/%s", BaseURL, modpackID), &project); err != nil {
+ return nil, err
}
return &project, nil
@@ -257,43 +200,10 @@ func (c *Client) GetModpack(ctx context.Context, modpackID string) (*ProjectDeta
// GetModpackVersions retrieves all versions for a specific modpack
func (c *Client) GetModpackVersions(ctx context.Context, modpackID string) ([]Version, error) {
- reqURL := fmt.Sprintf("%s/project/%s/version", BaseURL, modpackID)
-
- req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("User-Agent", c.config.Server.UserAgent)
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to execute request: %w", err)
- }
- defer func() {
- err := resp.Body.Close()
- if err != nil {
- fmt.Printf("failed to close response body: %v", err)
- }
- }()
-
- if resp.StatusCode != http.StatusOK {
- return nil, c.formatError(req, resp)
- }
-
var versions []Version
- if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
+ if err := c.http.DoJSON(ctx, fmt.Sprintf("%s/project/%s/version", BaseURL, modpackID), &versions); err != nil {
+ return nil, err
}
return versions, nil
}
-
-func (c *Client) formatError(req *http.Request, resp *http.Response) error {
- bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
- body := string(bodyBytes)
- if body != "" {
- return fmt.Errorf("modrinth API error: %s (url=%s body=%s)", resp.Status, req.URL.String(), body)
- }
- return fmt.Errorf("modrinth API error: %s (url=%s)", resp.Status, req.URL.String())
-}
diff --git a/internal/rbac/mapping.go b/internal/rbac/mapping.go
new file mode 100644
index 0000000..55b2ac3
--- /dev/null
+++ b/internal/rbac/mapping.go
@@ -0,0 +1,175 @@
+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,
+ "/discopanel.v1.AuthService/ValidateInvite": 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,
+ "/discopanel.v1.AuthService/CreateAPIToken": true,
+ "/discopanel.v1.AuthService/ListAPITokens": true,
+ "/discopanel.v1.AuthService/DeleteAPIToken": 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},
+ "/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"},
+ "/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..9084de0
--- /dev/null
+++ b/internal/rbac/rbac.go
@@ -0,0 +1,197 @@
+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
+}
+
+// Ensures default roles have their base permissions
+func (e *Enforcer) SeedDefaultPolicies(anonymousEnabled bool) error {
+ 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 role, rolePolicies := range policies {
+ existing, err := e.enforcer.GetFilteredPolicy(0, role)
+ if err != nil {
+ return err
+ }
+ 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
+ }
+ }
+ }
+
+ 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/handlers/openapi.go b/internal/rpc/handlers/openapi.go
new file mode 100644
index 0000000..109101a
--- /dev/null
+++ b/internal/rpc/handlers/openapi.go
@@ -0,0 +1,149 @@
+package handlers
+
+import (
+ "io/fs"
+ "net/http"
+ "strings"
+ "sync"
+
+ "github.com/nickheyer/discopanel/internal/rbac"
+ "github.com/nickheyer/discopanel/pkg/logger"
+ web "github.com/nickheyer/discopanel/web/discopanel"
+ "gopkg.in/yaml.v3"
+)
+
+// NewOpenAPIHandler returns an http.HandlerFunc that serves the OpenAPI spec.
+// Strips Connect protocol noise and injects per-operation security overrides.
+// When isAuthEnabled returns false, security schemes are removed entirely.
+func NewOpenAPIHandler(log *logger.Logger, isAuthEnabled func() bool) http.HandlerFunc {
+ var (
+ once sync.Once
+ authEnabled []byte
+ authDisabled []byte
+ )
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ once.Do(func() {
+ buildFS, err := web.BuildFS()
+ if err != nil {
+ log.Error("Failed to get frontend FS for OpenAPI spec: %v", err)
+ return
+ }
+
+ raw, err := fs.ReadFile(buildFS, "schemav1.yaml")
+ if err != nil {
+ log.Error("Failed to read OpenAPI spec: %v", err)
+ return
+ }
+
+ var doc map[string]any
+ if err := yaml.Unmarshal(raw, &doc); err != nil {
+ log.Error("Failed to parse OpenAPI spec: %v", err)
+ authEnabled = raw
+ authDisabled = raw
+ return
+ }
+
+ // Clean up generated spec
+ if paths, ok := doc["paths"].(map[string]any); ok {
+ for path, pathItem := range paths {
+ methods, ok := pathItem.(map[string]any)
+ if !ok {
+ continue
+ }
+ for _, methodVal := range methods {
+ op, ok := methodVal.(map[string]any)
+ if !ok {
+ continue
+ }
+ // Strip Connect-* header parameters
+ if params, ok := op["parameters"].([]any); ok {
+ filtered := params[:0]
+ for _, p := range params {
+ pm, ok := p.(map[string]any)
+ if !ok {
+ filtered = append(filtered, p)
+ continue
+ }
+ name, _ := pm["name"].(string)
+ if name == "Connect-Protocol-Version" || name == "Connect-Timeout-Ms" {
+ continue
+ }
+ filtered = append(filtered, p)
+ }
+ if len(filtered) == 0 {
+ delete(op, "parameters")
+ } else {
+ op["parameters"] = filtered
+ }
+ }
+ // Mark public operations as no-auth
+ procedure := "/" + strings.TrimPrefix(path, "/")
+ if rbac.PublicProcedures[procedure] {
+ op["security"] = []any{}
+ }
+ }
+ }
+ }
+
+ // Remove Connect-* schema definitions
+ if components, ok := doc["components"].(map[string]any); ok {
+ if schemas, ok := components["schemas"].(map[string]any); ok {
+ delete(schemas, "connect-protocol-version")
+ delete(schemas, "connect-timeout-header")
+ }
+ }
+
+ enabled, err := yaml.Marshal(doc)
+ if err != nil {
+ log.Error("Failed to marshal auth-enabled OpenAPI spec: %v", err)
+ authEnabled = raw
+ } else {
+ authEnabled = enabled
+ }
+
+ // Build auth-disabled variant: strip all security fields
+ delete(doc, "security")
+
+ if components, ok := doc["components"].(map[string]any); ok {
+ delete(components, "securitySchemes")
+ }
+
+ if paths, ok := doc["paths"].(map[string]any); ok {
+ for _, pathItem := range paths {
+ if methods, ok := pathItem.(map[string]any); ok {
+ for _, methodVal := range methods {
+ if op, ok := methodVal.(map[string]any); ok {
+ delete(op, "security")
+ }
+ }
+ }
+ }
+ }
+
+ stripped, err := yaml.Marshal(doc)
+ if err != nil {
+ log.Error("Failed to marshal stripped OpenAPI spec: %v", err)
+ authDisabled = raw
+ } else {
+ authDisabled = stripped
+ }
+ })
+
+ var spec []byte
+ if isAuthEnabled() {
+ spec = authEnabled
+ } else {
+ spec = authDisabled
+ }
+
+ if spec == nil {
+ http.Error(w, "OpenAPI spec not available", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/yaml")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Write(spec)
+ }
+}
diff --git a/internal/rpc/server.go b/internal/rpc/server.go
index 37d8860..fcda909 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,8 @@ 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/handlers"
"github.com/nickheyer/discopanel/internal/rpc/services"
"github.com/nickheyer/discopanel/internal/scheduler"
"github.com/nickheyer/discopanel/internal/ws"
@@ -25,6 +28,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 +41,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 +53,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 +86,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 +96,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 +141,7 @@ func (s *Server) setupHandler() {
discopanelv1connect.ModpackServiceName,
discopanelv1connect.ModuleServiceName,
discopanelv1connect.ProxyServiceName,
+ discopanelv1connect.RoleServiceName,
discopanelv1connect.ServerServiceName,
discopanelv1connect.SupportServiceName,
discopanelv1connect.TaskServiceName,
@@ -131,6 +154,15 @@ 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 dynamic OpenAPI spec
+ mux.HandleFunc("/api/v1/openapi.yaml", handlers.NewOpenAPIHandler(s.log, s.authManager.IsAnyAuthEnabled))
+
// Serve frontend for non-RPC routes
s.setupFrontend(mux)
@@ -141,7 +173,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 +184,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 +222,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 +250,81 @@ 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 ")
+ token = strings.TrimPrefix(strings.TrimPrefix(authHeader, "Bearer "), "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 != "" {
+ var err error
+ if strings.HasPrefix(token, "dp_") {
+ // API token authentication
+ user, err = s.authManager.ValidateAPIToken(ctx, token)
+ } else {
+ // Session/JWT authentication
+ 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 +332,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 +381,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 +426,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..9a2d420 100644
--- a/internal/rpc/services/auth.go
+++ b/internal/rpc/services/auth.go
@@ -2,421 +2,558 @@ package services
import (
"context"
+ "crypto/rand"
+ "encoding/base64"
"errors"
- "net/http"
+ "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"
"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
+ userCount, _ := s.store.CountUsers(ctx)
+ isFirstUser := userCount == 0
+
+ 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"))
+ }
+
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
+ // 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 {
- // 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"))
- }
+ // Increment invite use count after successful registration
+ if invite != nil {
+ _ = s.store.IncrementInviteUseCount(ctx, invite.ID)
+ }
+
+ roles, _ := s.store.GetUserRoleNames(ctx, user.ID)
- // New users default to viewer role
- role = storage.RoleViewer
+ return connect.NewResponse(&v1.RegisterResponse{
+ User: dbUserToProto(user, roles),
+ }), nil
+}
+
+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"))
}
- // 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"))
+ // 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,
+ }
+
+ 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,
+ })
+ }
+ }
+ }
+
+ return connect.NewResponse(&v1.GetCurrentUserResponse{
+ User: protoUser,
+ Permissions: permissions,
+ }), nil
}
- // 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)
+ // 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.RegisterResponse{
- User: dbUserToProto(user),
+ return connect.NewResponse(&v1.GetCurrentUserResponse{
+ User: dbUserToProto(dbUser, roles),
+ Permissions: permissions,
}), nil
}
-// ResetPassword resets password with recovery key
-func (s *AuthService) ResetPassword(ctx context.Context, req *connect.Request[v1.ResetPasswordRequest]) (*connect.Response[v1.ResetPasswordResponse], error) {
+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"))
+ }
+
msg := req.Msg
+ if msg.OldPassword == "" || msg.NewPassword == "" {
+ return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("old and new passwords are required"))
+ }
- 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"))
+ 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("Failed to reset password: %v", err)
- return nil, connect.NewError(connect.CodeInternal, errors.New("failed to reset password"))
+ s.log.Error("Change password failed: %v", err)
+ return nil, connect.NewError(connect.CodeInternal, errors.New("failed to change password"))
}
- return connect.NewResponse(&v1.ResetPasswordResponse{
- Message: "Password reset successfully",
+ return connect.NewResponse(&v1.ChangePasswordResponse{
+ Message: "password changed",
+ }), nil
+}
+
+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.GetOIDCLoginURLResponse{
+ LoginUrl: "/api/v1/auth/oidc/login",
}), nil
}
-// 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)
+ 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 get auth config: %v", err)
- return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get auth config"))
+ s.log.Error("Failed to count users: %v", err)
+ userCount = 0
}
- // 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"))
- }
+ resp := &v1.GetAuthConfigResponse{
+ LocalAuthEnabled: cfg.Local.Enabled,
+ AllowRegistration: cfg.Local.AllowRegistration,
+ AnonymousAccess: cfg.AnonymousAccess,
+ SessionTimeout: int32(cfg.SessionTimeout),
+ OidcEnabled: oidcEnabled,
+ FirstUserSetup: userCount == 0,
}
- return connect.NewResponse(&v1.GetAuthConfigResponse{
- Enabled: config.Enabled,
- SessionTimeout: int32(config.SessionTimeout),
- RequireEmailVerify: config.RequireEmailVerify,
- AllowRegistration: config.AllowRegistration,
- }), nil
+ 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
}
-// UpdateAuthConfig modifies auth configuration
-func (s *AuthService) UpdateAuthConfig(ctx context.Context, req *connect.Request[v1.UpdateAuthConfigRequest]) (*connect.Response[v1.UpdateAuthConfigResponse], error) {
+func (s *AuthService) UpdateAuthSettings(ctx context.Context, req *connect.Request[v1.UpdateAuthSettingsRequest]) (*connect.Response[v1.UpdateAuthSettingsResponse], error) {
msg := req.Msg
- config, _, err := s.store.GetAuthConfig(ctx)
+ 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"))
+ }
+
+ // Return the updated config
+ configResp, err := s.GetAuthConfig(ctx, connect.NewRequest(&v1.GetAuthConfigRequest{}))
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 nil, err
}
- // 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"))
+ return connect.NewResponse(&v1.UpdateAuthSettingsResponse{
+ 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)
- // 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)
+ // 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 {
- s.log.Error("Failed to check user count: %v", err)
- return nil, connect.NewError(connect.CodeInternal, errors.New("failed to check user count"))
+ return nil, connect.NewError(connect.CodeInternal, errors.New("failed to hash PIN"))
}
+ pinHash = string(hash)
+ }
- 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
- }
+ // 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
}
- // Update allowed fields
- if msg.Enabled != nil {
- config.Enabled = *msg.Enabled
+ // Get creator from context
+ authUser := auth.GetUserFromContext(ctx)
+ createdBy := ""
+ if authUser != nil {
+ createdBy = authUser.Username
}
- if msg.SessionTimeout != nil {
- config.SessionTimeout = int(*msg.SessionTimeout)
+
+ invite := &storage.RegistrationInvite{
+ Code: code,
+ Description: msg.Description,
+ Roles: msg.Roles,
+ PinHash: pinHash,
+ MaxUses: int(msg.MaxUses),
+ ExpiresAt: expiresAt,
+ CreatedBy: createdBy,
}
- if msg.RequireEmailVerify != nil {
- config.RequireEmailVerify = *msg.RequireEmailVerify
+
+ 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"))
}
- if msg.AllowRegistration != nil {
- config.AllowRegistration = *msg.AllowRegistration
+
+ 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"))
}
- 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"))
+ protoInvites := make([]*v1.RegistrationInvite, 0, len(invites))
+ for _, inv := range invites {
+ protoInvites = append(protoInvites, dbInviteToProto(inv))
}
- return connect.NewResponse(&v1.UpdateAuthConfigResponse{
- Message: "Auth config updated successfully",
- RequiresFirstUser: false,
+ return connect.NewResponse(&v1.ListInvitesResponse{
+ Invites: protoInvites,
}), nil
}
-// GetCurrentUser gets authenticated user info
-func (s *AuthService) GetCurrentUser(ctx context.Context, req *connect.Request[v1.GetCurrentUserRequest]) (*connect.Response[v1.GetCurrentUserResponse], error) {
+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 (s *AuthService) CreateAPIToken(ctx context.Context, req *connect.Request[v1.CreateAPITokenRequest]) (*connect.Response[v1.CreateAPITokenResponse], error) {
user := auth.GetUserFromContext(ctx)
if user == nil {
return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("not authenticated"))
}
- return connect.NewResponse(&v1.GetCurrentUserResponse{
- User: dbUserToProto(user),
+ msg := req.Msg
+ if msg.Name == "" {
+ return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("token name is required"))
+ }
+
+ plaintext, apiToken, err := s.authManager.GenerateAPIToken(ctx, user.ID, msg.Name, msg.ExpiresInDays)
+ if err != nil {
+ s.log.Error("Failed to create API token: %v", err)
+ return nil, connect.NewError(connect.CodeInternal, errors.New("failed to create API token"))
+ }
+
+ return connect.NewResponse(&v1.CreateAPITokenResponse{
+ PlaintextToken: plaintext,
+ ApiToken: dbAPITokenToProto(apiToken),
}), nil
}
-// ChangePassword changes user's own password
-func (s *AuthService) ChangePassword(ctx context.Context, req *connect.Request[v1.ChangePasswordRequest]) (*connect.Response[v1.ChangePasswordResponse], error) {
- msg := req.Msg
-
+func (s *AuthService) ListAPITokens(ctx context.Context, req *connect.Request[v1.ListAPITokensRequest]) (*connect.Response[v1.ListAPITokensResponse], error) {
user := auth.GetUserFromContext(ctx)
if user == nil {
return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("not authenticated"))
}
- 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"))
+ tokens, err := s.store.ListAPITokensByUser(ctx, user.ID)
+ if err != nil {
+ s.log.Error("Failed to list API tokens: %v", err)
+ return nil, connect.NewError(connect.CodeInternal, errors.New("failed to list API tokens"))
}
- return connect.NewResponse(&v1.ChangePasswordResponse{
- Message: "Password changed successfully",
+ protoTokens := make([]*v1.ApiToken, 0, len(tokens))
+ for _, t := range tokens {
+ protoTokens = append(protoTokens, dbAPITokenToProto(&t))
+ }
+
+ return connect.NewResponse(&v1.ListAPITokensResponse{
+ ApiTokens: protoTokens,
}), nil
}
+
+func (s *AuthService) DeleteAPIToken(ctx context.Context, req *connect.Request[v1.DeleteAPITokenRequest]) (*connect.Response[v1.DeleteAPITokenResponse], error) {
+ user := auth.GetUserFromContext(ctx)
+ if user == nil {
+ return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("not authenticated"))
+ }
+
+ if req.Msg.Id == "" {
+ return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("token ID is required"))
+ }
+
+ if err := s.store.DeleteAPIToken(ctx, req.Msg.Id, user.ID); err != nil {
+ s.log.Error("Failed to delete API token: %v", err)
+ return nil, connect.NewError(connect.CodeNotFound, errors.New("API token not found"))
+ }
+
+ return connect.NewResponse(&v1.DeleteAPITokenResponse{}), nil
+}
+
+func dbAPITokenToProto(t *storage.APIToken) *v1.ApiToken {
+ pt := &v1.ApiToken{
+ Id: t.ID,
+ Name: t.Name,
+ CreatedAt: timestamppb.New(t.CreatedAt),
+ }
+ if t.ExpiresAt != nil {
+ pt.ExpiresAt = timestamppb.New(*t.ExpiresAt)
+ }
+ if t.LastUsedAt != nil {
+ pt.LastUsedAt = timestamppb.New(*t.LastUsedAt)
+ }
+ return pt
+}
+
+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/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/modpack.go b/internal/rpc/services/modpack.go
index b22bd90..d579b45 100644
--- a/internal/rpc/services/modpack.go
+++ b/internal/rpc/services/modpack.go
@@ -4,6 +4,7 @@ import (
"archive/zip"
"context"
"encoding/json"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -18,8 +19,8 @@ import (
storage "github.com/nickheyer/discopanel/internal/db"
"github.com/nickheyer/discopanel/internal/docker"
"github.com/nickheyer/discopanel/internal/indexers"
- "github.com/nickheyer/discopanel/internal/indexers/fuego"
- "github.com/nickheyer/discopanel/internal/indexers/modrinth"
+ _ "github.com/nickheyer/discopanel/internal/indexers/fuego"
+ _ "github.com/nickheyer/discopanel/internal/indexers/modrinth"
"github.com/nickheyer/discopanel/internal/minecraft"
"github.com/nickheyer/discopanel/pkg/files"
"github.com/nickheyer/discopanel/pkg/logger"
@@ -50,6 +51,46 @@ func NewModpackService(store *storage.Store, cfg *config.Config, uploadManager *
}
}
+// getIndexer creates an indexer by name, looking up the fuego API key from settings when needed.
+func (s *ModpackService) getIndexer(ctx context.Context, name string) (indexers.ModpackIndexer, error) {
+ apiKey := ""
+ if name == "fuego" {
+ globalSettings, _, err := s.store.GetGlobalSettings(ctx)
+ if err != nil || globalSettings == nil {
+ return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to get global settings"))
+ }
+ if globalSettings.CFAPIKey != nil {
+ apiKey = *globalSettings.CFAPIKey
+ }
+ if apiKey == "" {
+ return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("CurseForge API key not configured"))
+ }
+ }
+ idx, err := indexers.NewIndexer(name, apiKey, s.config)
+ if err != nil {
+ return nil, connect.NewError(connect.CodeInvalidArgument, err)
+ }
+ return idx, nil
+}
+
+// mapIndexerError maps IndexerError kinds to appropriate connect error codes.
+func mapIndexerError(err error, msg string) *connect.Error {
+ var ie *indexers.IndexerError
+ if errors.As(err, &ie) {
+ switch ie.Kind {
+ case indexers.ErrRateLimit:
+ return connect.NewError(connect.CodeResourceExhausted, fmt.Errorf("%s: %w", msg, err))
+ case indexers.ErrAuth:
+ return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("%s: %w", msg, err))
+ case indexers.ErrNotFound:
+ return connect.NewError(connect.CodeNotFound, fmt.Errorf("%s: %w", msg, err))
+ case indexers.ErrNetwork:
+ return connect.NewError(connect.CodeUnavailable, fmt.Errorf("%s: %w", msg, err))
+ }
+ }
+ return connect.NewError(connect.CodeInternal, fmt.Errorf("%s: %w", msg, err))
+}
+
// SearchModpacks searches for modpacks
func (s *ModpackService) SearchModpacks(ctx context.Context, req *connect.Request[v1.SearchModpacksRequest]) (*connect.Response[v1.SearchModpacksResponse], error) {
msg := req.Msg
@@ -318,40 +359,23 @@ func (s *ModpackService) GetModpackVersions(ctx context.Context, req *connect.Re
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("modpack not found"))
}
- // Get appropriate indexer client
- var indexerClient indexers.ModpackIndexer
- switch modpack.Indexer {
- case "fuego":
- // Get API key from global settings
- globalSettings, _, err := s.store.GetGlobalSettings(ctx)
- if err != nil || globalSettings == nil {
- return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to get global settings"))
- }
-
- apiKey := ""
- if globalSettings.CFAPIKey != nil {
- apiKey = *globalSettings.CFAPIKey
- }
- if apiKey == "" {
- return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("CurseForge API key not configured"))
- }
- indexerClient = fuego.NewIndexer(apiKey, s.config)
- case "modrinth":
- indexerClient = modrinth.NewIndexer(s.config)
- case "manual":
- // For manual modpacks, return empty list
+ // Manual modpacks have no remote versions
+ if modpack.Indexer == "manual" {
return connect.NewResponse(&v1.GetModpackVersionsResponse{
Versions: []*v1.Version{},
}), nil
- default:
- return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("unknown indexer: %s", modpack.Indexer))
+ }
+
+ indexerClient, err := s.getIndexer(ctx, modpack.Indexer)
+ if err != nil {
+ return nil, err
}
// Get files from the indexer
files, err := indexerClient.GetModpackFiles(ctx, modpack.IndexerID)
if err != nil {
s.log.Error("Failed to get modpack files from %s: %v", modpack.Indexer, err)
- return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to get modpack versions"))
+ return nil, mapIndexerError(err, "failed to get modpack versions")
}
// Convert files to versions
@@ -387,39 +411,16 @@ func (s *ModpackService) SyncModpacks(ctx context.Context, req *connect.Request[
indexer = "fuego"
}
- var indexerClient indexers.ModpackIndexer
-
- switch indexer {
- case "fuego":
- // Get Fuego API key from global settings
- globalSettings, _, err := s.store.GetGlobalSettings(ctx)
- if err != nil {
- s.log.Error("Failed to get global settings: %v", err)
- return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to get global settings"))
- }
-
- apiKey := ""
- if globalSettings.CFAPIKey != nil {
- apiKey = *globalSettings.CFAPIKey
- }
-
- if apiKey == "" {
- return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("fuego API key not configured in global settings"))
- }
-
- indexerClient = fuego.NewIndexer(apiKey, s.config)
- case "modrinth":
- // Modrinth doesn't require an API key for public operations
- indexerClient = modrinth.NewIndexer(s.config)
- default:
- return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("unknown indexer: %s", indexer))
+ indexerClient, err := s.getIndexer(ctx, indexer)
+ if err != nil {
+ return nil, err
}
// Search modpacks using the indexer
searchResp, err := indexerClient.SearchModpacks(ctx, msg.Query, msg.GameVersion, msg.ModLoader, 0, 50)
if err != nil {
s.log.Error("Failed to search %s: %v", indexer, err)
- return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to search %s: %w", indexer, err))
+ return nil, mapIndexerError(err, fmt.Sprintf("failed to search %s", indexer))
}
// Store modpacks in database
@@ -903,39 +904,16 @@ func (s *ModpackService) SyncModpackFiles(ctx context.Context, req *connect.Requ
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("modpack not found"))
}
- var indexerClient indexers.ModpackIndexer
-
- switch modpack.Indexer {
- case "fuego":
- // Get Fuego API key from global settings
- globalSettings, _, err := s.store.GetGlobalSettings(ctx)
- if err != nil {
- s.log.Error("Failed to get global settings: %v", err)
- return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to get global settings"))
- }
-
- apiKey := ""
- if globalSettings.CFAPIKey != nil {
- apiKey = *globalSettings.CFAPIKey
- }
-
- if apiKey == "" {
- return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("fuego API key not configured in global settings"))
- }
-
- indexerClient = fuego.NewIndexer(apiKey, s.config)
- case "modrinth":
- // Modrinth doesn't require an API key for public operations
- indexerClient = modrinth.NewIndexer(s.config)
- default:
- return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("unknown indexer: %s", modpack.Indexer))
+ indexerClient, err := s.getIndexer(ctx, modpack.Indexer)
+ if err != nil {
+ return nil, err
}
// Get files from the indexer
files, err := indexerClient.GetModpackFiles(ctx, modpack.IndexerID)
if err != nil {
s.log.Error("Failed to get modpack files: %v", err)
- return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to get modpack files"))
+ return nil, mapIndexerError(err, "failed to get modpack files")
}
// Store files in database
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..6b3c61a 100644
--- a/internal/ws/hub.go
+++ b/internal/ws/hub.go
@@ -3,6 +3,7 @@ package ws
import (
"context"
"net/http"
+ "strings"
"sync"
"time"
@@ -10,6 +11,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"
@@ -33,6 +35,7 @@ const (
type Hub struct {
logStreamer *logger.LogStreamer
authManager *auth.Manager
+ enforcer *rbac.Enforcer
store *storage.Store
docker *docker.Client
log *logger.Logger
@@ -55,7 +58,7 @@ type Client struct {
send chan []byte
// Authentication
- user *storage.User
+ user *auth.AuthenticatedUser
authenticated bool
// Subscriptions: serverId -> log channel
@@ -64,10 +67,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,
authManager: authManager,
+ enforcer: enforcer,
store: store,
docker: docker,
log: log,
@@ -216,16 +220,50 @@ func (c *Client) handleAuth(msg *v1.AuthMessage) {
return
}
- ctx := context.Background()
- user, err := c.hub.authManager.ValidateSession(ctx, msg.Token)
- if err != nil {
- c.sendAuthFail("invalid token")
+ // 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
}
- c.user = user
- c.authenticated = true
- c.sendAuthOk()
+ ctx := context.Background()
+
+ if msg.Token != "" {
+ var user *auth.AuthenticatedUser
+ var err error
+ if strings.HasPrefix(msg.Token, "dp_") {
+ user, err = c.hub.authManager.ValidateAPIToken(ctx, msg.Token)
+ } else {
+ 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 +278,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 +295,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 +324,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 +377,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 +457,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..fa893c2
--- /dev/null
+++ b/oidc/authelia/docker-compose.yaml
@@ -0,0 +1,67 @@
+# 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:
+ image: nickheyer/discopanel:dev
+ container_name: discopanel
+ restart: unless-stopped
+ 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
+
+ # ------------------------------------ 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..b43dde9
--- /dev/null
+++ b/oidc/keycloak/docker-compose.yaml
@@ -0,0 +1,100 @@
+# 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
+ - /tmp/discopanel:/app/data
+ environment:
+ - DISCOPANEL_DATA_DIR=/app/data
+ - DISCOPANEL_HOST_DATA_PATH=/tmp/discopanel
+ - 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/realm.json:/opt/keycloak/data/import/realm.json: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/pkg/utils/strings.go b/pkg/utils/strings.go
new file mode 100644
index 0000000..830f32d
--- /dev/null
+++ b/pkg/utils/strings.go
@@ -0,0 +1,13 @@
+package utils
+
+func DeduplicateStrings(strings []string) []string {
+ seen := make(map[string]bool)
+ result := []string{}
+ for _, s := range strings {
+ if !seen[s] {
+ seen[s] = true
+ result = append(result, s)
+ }
+ }
+ return result
+}
diff --git a/proto/discopanel/v1/auth.proto b/proto/discopanel/v1/auth.proto
index d836cb5..7771f67 100644
--- a/proto/discopanel/v1/auth.proto
+++ b/proto/discopanel/v1/auth.proto
@@ -3,30 +3,83 @@ syntax = "proto3";
package discopanel.v1;
import "discopanel/v1/common.proto";
+import "gnostic/openapi/v3/annotations.proto";
import "google/protobuf/timestamp.proto";
option go_package = "github.com/nickheyer/discopanel/pkg/proto/discopanel/v1;discopanelv1";
+// Global OpenAPI security: Bearer token auth via session JWT or API token (dp_...)
+option (gnostic.openapi.v3.document) = {
+ info: {
+ title: "DiscoPanel API"
+ version: "2.x"
+ description: "DiscoPanel server management API. Authenticate using a session token (from Login/OIDC) or an API token (dp_... prefix) in the Authorization header.\n\n## Connect Protocol\n\nThis API uses the [Connect protocol](https://connectrpc.com/docs/protocol) over HTTP. All endpoints accept `POST` with `application/json` bodies. Connect clients may send the following optional headers:\n\n- `Connect-Protocol-Version: 1` — declares the Connect protocol version.\n- `Connect-Timeout-Ms: ` — request deadline in milliseconds.\n\nThese headers are handled automatically by Connect client libraries and are not required when calling the API directly with curl or other HTTP clients."
+ }
+ components: {
+ security_schemes: {
+ additional_properties: [
+ {
+ name: "BearerAuth"
+ value: {
+ security_scheme: {
+ type: "http"
+ scheme: "bearer"
+ description: "Session JWT from login, or API token with dp_ prefix"
+ }
+ }
+ }
+ ]
+ }
+ }
+ security: [
+ {
+ additional_properties: [
+ {
+ name: "BearerAuth"
+ value: {
+ value: []
+ }
+ }
+ ]
+ }
+ ]
+};
-// Authentication and authorization service
+// Authentication service
service AuthService {
- // Check if auth is enabled
+ // Check auth system status (public)
rpc GetAuthStatus(GetAuthStatusRequest) returns (GetAuthStatusResponse);
- // Authenticate user credentials
+ // Authenticate with local credentials (public)
rpc Login(LoginRequest) returns (LoginResponse);
- // Invalidate session token
+ // Invalidate session
rpc Logout(LogoutRequest) returns (LogoutResponse);
- // Create new user account
+ // Register new local account (public)
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 (public)
+ rpc GetOIDCLoginURL(GetOIDCLoginURLRequest) returns (GetOIDCLoginURLResponse);
+ // Get full auth configuration
+ 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)
+ rpc ValidateInvite(ValidateInviteRequest) returns (ValidateInviteResponse);
+ // Create a new API token for the authenticated user
+ rpc CreateAPIToken(CreateAPITokenRequest) returns (CreateAPITokenResponse);
+ // List all API tokens for the authenticated user
+ rpc ListAPITokens(ListAPITokensRequest) returns (ListAPITokensResponse);
+ // Delete/revoke an API token
+ rpc DeleteAPIToken(DeleteAPITokenRequest) returns (DeleteAPITokenResponse);
}
// Empty auth status request
@@ -34,12 +87,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,70 +115,179 @@ message LogoutResponse {
string message = 1;
}
-// New account info
+// New local account
message RegisterRequest {
string username = 1;
string email = 2;
string password = 3;
+ optional string invite_code = 4;
+ optional string invite_pin = 5;
}
-// 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;
+// Empty current user request
+message GetCurrentUserRequest {}
+
+// Authenticated user with permissions
+message GetCurrentUserResponse {
+ User user = 1;
+ repeated Permission permissions = 2;
}
-// Reset confirmation
-message ResetPasswordResponse {
+// Password change
+message ChangePasswordRequest {
+ string old_password = 1;
+ string new_password = 2;
+}
+
+// Password change confirmation
+message ChangePasswordResponse {
string message = 1;
}
-// Empty config request
+// OIDC login URL request
+message GetOIDCLoginURLRequest {}
+
+// OIDC login redirect URL
+message GetOIDCLoginURLResponse {
+ string login_url = 1;
+}
+
+// Empty auth config request
message GetAuthConfigRequest {}
-// Current auth settings
+// Full auth configuration for admin display
message GetAuthConfigResponse {
- bool enabled = 1;
- int32 session_timeout = 2;
- bool require_email_verify = 3;
- bool allow_registration = 4;
+ 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;
}
-// 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;
+// 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;
}
-// Config update result
-message UpdateAuthConfigResponse {
- string message = 1;
- bool requires_first_user = 2; // If enabling auth with no users
+// Returns the updated auth configuration
+message UpdateAuthSettingsResponse {
+ GetAuthConfigResponse config = 1;
}
-// Empty current user request
-message GetCurrentUserRequest {}
+// 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;
+}
-// Authenticated user info
-message GetCurrentUserResponse {
- User user = 1;
+// New invite with roles, optional PIN, and expiry
+message CreateInviteRequest {
+ string description = 1;
+ repeated string roles = 2;
+ optional string pin = 3;
+ int32 max_uses = 4;
+ optional int32 expires_in_hours = 5;
}
-// Password change credentials
-message ChangePasswordRequest {
- string old_password = 1;
- string new_password = 2;
+// Created invite details
+message CreateInviteResponse {
+ RegistrationInvite invite = 1;
}
-// Password change confirmation
-message ChangePasswordResponse {
- string message = 1;
+// Empty list invites request
+message ListInvitesRequest {}
+
+// All registration invites
+message ListInvitesResponse {
+ repeated RegistrationInvite invites = 1;
+}
+
+// Invite lookup by ID
+message GetInviteRequest {
+ string id = 1;
+}
+
+// Single invite details
+message GetInviteResponse {
+ RegistrationInvite invite = 1;
+}
+
+// Invite deletion by ID
+message DeleteInviteRequest {
+ string id = 1;
+}
+
+// Empty delete invite confirmation
+message DeleteInviteResponse {}
+
+// Invite code to validate
+message ValidateInviteRequest {
+ string code = 1;
+}
+
+// Invite validity and PIN requirement
+message ValidateInviteResponse {
+ bool valid = 1;
+ bool requires_pin = 2;
+ string description = 3;
}
+
+// API Token metadata (never includes the token value)
+message ApiToken {
+ string id = 1;
+ string name = 2;
+ google.protobuf.Timestamp expires_at = 3;
+ google.protobuf.Timestamp last_used_at = 4;
+ google.protobuf.Timestamp created_at = 5;
+}
+
+// New API token with name and optional expiry
+message CreateAPITokenRequest {
+ string name = 1;
+ optional int32 expires_in_days = 2;
+}
+
+// Plaintext token (shown once) and metadata
+message CreateAPITokenResponse {
+ string plaintext_token = 1;
+ ApiToken api_token = 2;
+}
+
+// Empty list API tokens request
+message ListAPITokensRequest {}
+
+// All API tokens for the authenticated user
+message ListAPITokensResponse {
+ repeated ApiToken api_tokens = 1;
+}
+
+// API token deletion by ID
+message DeleteAPITokenRequest {
+ string id = 1;
+}
+
+// Empty delete API token confirmation
+message DeleteAPITokenResponse {}
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..8900a41 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}
-
-
-
-
-
- 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}
-