From 194cf1e2c0b7b6b8098f3b4b6b16f00e198e973b Mon Sep 17 00:00:00 2001 From: vitorio92 Date: Fri, 3 Apr 2026 09:54:34 +0200 Subject: [PATCH 01/24] Add local user authentication (ADR-001) Implement JWT-based local authentication with SQLite user storage, configurable via auth.enabled toggle. Includes short-lived access tokens (15min default) with long-lived refresh tokens (30 days) for persistent sessions without re-login. Backend: - New db/ package: SQLite database with users and refresh_tokens tables - New middleware/auth.go: JWT validation middleware (passthrough when auth disabled) - New handler/auth.go: login, refresh, logout, user CRUD endpoints with rate limiting (5 attempts/IP/min) and self-deletion guard - config.go: AuthConfig with Viper defaults and env var bindings - main.go: DB initialization, --create-admin CLI flag, periodic token cleanup - Meta endpoint moved to public (needed for auth state detection) Frontend: - New auth Pinia store (persisted) with token management - New LoginPage.vue with centered card login form - New AuthLayout.vue (minimal layout for login page) - Router guard: redirects unauthenticated users to /login - Axios interceptors: auto-inject Bearer token, silent 401 refresh - MainLayout.vue: user menu with logout button - Backend client: login/refresh/logout/user CRUD methods Also includes ADR documents and CLAUDE.md for the repository. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 81 ++++ CONFIGURATION.md | 38 ++ config/config.go | 25 ++ db/db.go | 84 ++++ db/tokens.go | 113 +++++ db/users.go | 210 +++++++++ docs/adr/ADR-001-local-user-authentication.md | 406 +++++++++++++++++ docs/adr/ADR-002-saml-authentication.md | 412 ++++++++++++++++++ go.mod | 6 +- handler/auth.go | 308 +++++++++++++ main.go | 178 ++++++-- middleware/auth.go | 103 +++++ ui/src/boot/axios.ts | 130 ++++-- ui/src/client/backend.ts | 34 +- ui/src/client/models.ts | 32 ++ ui/src/layouts/AuthLayout.vue | 7 + ui/src/layouts/MainLayout.vue | 47 +- ui/src/pages/LoginPage.vue | 135 ++++++ ui/src/router/index.ts | 17 + ui/src/router/routes.ts | 12 + ui/src/stores/auth.ts | 77 ++++ 21 files changed, 2400 insertions(+), 55 deletions(-) create mode 100644 CLAUDE.md create mode 100644 db/db.go create mode 100644 db/tokens.go create mode 100644 db/users.go create mode 100644 docs/adr/ADR-001-local-user-authentication.md create mode 100644 docs/adr/ADR-002-saml-authentication.md create mode 100644 handler/auth.go create mode 100644 middleware/auth.go create mode 100644 ui/src/layouts/AuthLayout.vue create mode 100644 ui/src/pages/LoginPage.vue create mode 100644 ui/src/stores/auth.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1e13f29 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,81 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Project Is + +OpenVox View is a web-based dashboard for PuppetDB/OpenVoxDB infrastructure monitoring, inspired by Puppetboard. It consists of a Go backend API server and a Vue 3 + Quasar frontend. + +## Build & Development Commands + +### Full Build +```bash +make all # Build everything (frontend first, then backend binary) +make ui # Build frontend only (yarn install + yarn build) +make backend # Build Go binary (requires frontend to be built first) +``` + +### Development (Hot Reload) +```bash +make develop-backend # Go backend with hot reload via `air` +make develop-frontend # Frontend dev server at http://localhost:9000 (proxies to backend at :5000) +make develop-backend-crafty # Backend with Puppet TLS settings (PUPPETDB_TLS_IGNORE=true, port 8081) +``` + +### Frontend (from `ui/` directory) +```bash +yarn lint # ESLint +yarn format # Prettier +yarn test # (stubbed, no tests currently) +``` + +### Go Backend +```bash +go build ./... # Build +go vet ./... # Vet +``` + +## Architecture + +### Data Flow +Frontend → Go API (`/api/v1/*`) → PuppetDB or Puppet CA + +The backend embeds the built frontend assets via `uiserve.go` so the whole app ships as a single binary. + +### Backend (Go + Gin) + +- **[main.go](main.go)** — Gin router setup, CORS config, route registration for all `/api/v1/*` endpoints +- **[uiserve.go](uiserve.go)** — Serves embedded Vue build assets for non-API routes +- **[config/config.go](config/config.go)** — Viper-based config loading from `config.yaml` or environment variables (see CONFIGURATION.md) +- **[handler/](handler/)** — HTTP handlers grouped by domain: + - `view.go` — Node overview, metrics, predefined views, fact rendering + - `pdb.go` — PuppetDB query execution, query history (in-memory), predefined queries + - `ca.go` — Puppet CA certificate management (sign/revoke/clean) + - `core.go` — Shared response helpers +- **[model/](model/)** — Domain structs (nodes, facts, events, certificates, metrics) +- **[puppetdb/client.go](puppetdb/client.go)** — PuppetDB HTTP client (supports TLS + client certs) +- **[puppetca/client.go](puppetca/client.go)** — Puppet CA HTTP client + +### Frontend (Vue 3 + Quasar + TypeScript, in `ui/src/`) + +- **[ui/src/pages/](ui/src/pages/)** — Route-level page components +- **[ui/src/components/](ui/src/components/)** — Reusable Vue components +- **[ui/src/stores/](ui/src/stores/)** — Pinia state stores +- **[ui/src/client/](ui/src/client/)** — Axios-based API client for the Go backend +- **[ui/src/puppet/](ui/src/puppet/)** — Puppet-domain utilities +- **[ui/src/router/](ui/src/router/)** — Vue Router config +- **[ui/src/i18n/](ui/src/i18n/)** — i18n translations + +### API Structure +All backend endpoints are under `/api/v1/`: +- `GET /meta` — App metadata (CA enabled flag, unreported hours threshold, etc.) +- `GET /version` — Version info +- `/view/*` — Dashboard views (nodes, metrics, facts) +- `/pdb/*` — PuppetDB query execution and history +- `/ca/*` — Certificate management + +### Key Config Points +- Default backend port: **5000** +- Config file: `config.yaml` (see CONFIGURATION.md for all options) +- TLS is fully supported for both PuppetDB and Puppet CA connections +- Query history is stored in-memory in `PdbHandler` diff --git a/CONFIGURATION.md b/CONFIGURATION.md index ab91894..4c53fab 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -32,6 +32,37 @@ but you can pass the -config parameter to define the location of the config file | puppetca.deactivate_nodes | PUPPETCA_DEACTIVATE_NODES | false | bool | Also deactivate node in PuppetDB with revoke / clean | +### Authentication + +| Option | Environment Variable | Default | Type | Description | +|------------------------------|----------------------------------------------|-----------------------|--------|--------------------------------------------------| +| auth.enabled | OPENVOXVIEW_AUTH_ENABLED | false | bool | Enable local user authentication | +| auth.jwt_secret | OPENVOXVIEW_AUTH_JWT_SECRET | | string | Secret for signing JWT tokens (min 32 chars) | +| auth.access_token_ttl_minutes| OPENVOXVIEW_AUTH_ACCESS_TOKEN_TTL_MINUTES | 15 | int | Access token lifetime in minutes | +| auth.refresh_token_ttl_days | OPENVOXVIEW_AUTH_REFRESH_TOKEN_TTL_DAYS | 30 | int | Refresh token lifetime in days | +| auth.db_path | OPENVOXVIEW_AUTH_DB_PATH | data/openvoxview.db | string | Path to SQLite database file | + +When `auth.enabled` is `true`, all API endpoints (except `/api/v1/auth/login`, `/api/v1/auth/refresh`, and `/api/v1/version`) require a valid JWT bearer token. If no `jwt_secret` is configured, a random one is generated at startup (tokens will not survive restarts). + +To create the first admin user, run: + +``` +openvoxview --create-admin +``` + +Users can also be managed via the API endpoints when authenticated: + +| Method | Endpoint | Description | +|--------|-----------------------------|------------------------| +| POST | /api/v1/auth/login | Login (returns tokens) | +| POST | /api/v1/auth/refresh | Refresh access token | +| POST | /api/v1/auth/logout | Revoke refresh token | +| GET | /api/v1/auth/me | Current user profile | +| GET | /api/v1/auth/users | List all users | +| POST | /api/v1/auth/users | Create user | +| PUT | /api/v1/auth/users/:id | Update user | +| DELETE | /api/v1/auth/users/:id | Delete user | + ### predefined Queries | Option | Type | Description | |-------------|--------|---------------------------| @@ -79,6 +110,13 @@ port: 5000 trusted_proxies: - 127.0.0.1 +auth: + enabled: true + jwt_secret: "change-me-to-a-long-random-string-min-32-chars" + access_token_ttl_minutes: 15 + refresh_token_ttl_days: 30 + db_path: "data/openvoxview.db" + puppetdb: host: localhost port: 8081 diff --git a/config/config.go b/config/config.go index dade68f..06cb8b4 100644 --- a/config/config.go +++ b/config/config.go @@ -12,6 +12,7 @@ import ( var configPath = flag.String("config", "", "path to the config file") var printVersion = flag.Bool("version", false, "prints version") +var createAdmin = flag.Bool("create-admin", false, "create an admin user interactively") func init() { flag.Parse() @@ -22,6 +23,14 @@ type ConfigPqlQuery struct { Query string `mapstructure:"query"` } +type AuthConfig struct { + Enabled bool `mapstructure:"enabled"` + JwtSecret string `mapstructure:"jwt_secret"` + AccessTokenTTL int `mapstructure:"access_token_ttl_minutes"` + RefreshTokenTTL int `mapstructure:"refresh_token_ttl_days"` + DbPath string `mapstructure:"db_path"` +} + type Config struct { Listen string `mapstructure:"listen"` Port uint64 `mapstructure:"port"` @@ -39,6 +48,7 @@ type Config struct { Views []model.View `mapstructure:"views"` UnreportedHours uint64 `mapstructure:"unreported_hours"` StripPathPrefix string `mapstructure:"strip_path_prefix"` + Auth AuthConfig `mapstructure:"auth"` PuppetCA struct { Host string `mapstructure:"host"` Port uint64 `mapstructure:"port"` @@ -60,6 +70,10 @@ func PrintVersion(version string) bool { return false } +func CreateAdmin() bool { + return *createAdmin +} + var ( cachedConfig *Config cachedErr error @@ -83,6 +97,11 @@ func GetConfig() (*Config, error) { viper.SetDefault("puppetdb.tls_ignore", false) viper.SetDefault("unreported_hours", 3) viper.SetDefault("strip_path_prefix", `/etc/puppetlabs/code/environments(/.*?/modules)?`) + viper.SetDefault("auth.enabled", false) + viper.SetDefault("auth.access_token_ttl_minutes", 15) + viper.SetDefault("auth.refresh_token_ttl_days", 30) + viper.SetDefault("auth.db_path", "data/openvoxview.db") + viper.SetDefault("puppetca.port", 8140) viper.SetDefault("puppetca.tls", true) viper.SetDefault("puppetca.tls_ignore", false) @@ -103,6 +122,12 @@ func GetConfig() (*Config, error) { viper.BindEnv("puppetdb.tls_cert", "PUPPETDB_TLS_CERT") viper.BindEnv("unreported_hours", "UNREPORTED_HOURS") viper.BindEnv("strip_path_prefix", "STRIP_PATH_PREFIX") + viper.BindEnv("auth.enabled", "OPENVOXVIEW_AUTH_ENABLED") + viper.BindEnv("auth.jwt_secret", "OPENVOXVIEW_AUTH_JWT_SECRET") + viper.BindEnv("auth.access_token_ttl_minutes", "OPENVOXVIEW_AUTH_ACCESS_TOKEN_TTL_MINUTES") + viper.BindEnv("auth.refresh_token_ttl_days", "OPENVOXVIEW_AUTH_REFRESH_TOKEN_TTL_DAYS") + viper.BindEnv("auth.db_path", "OPENVOXVIEW_AUTH_DB_PATH") + viper.BindEnv("puppetca.host", "PUPPETCA_HOST") viper.BindEnv("puppetca.port", "PUPPETCA_PORT") viper.BindEnv("puppetca.tls", "PUPPETCA_TLS") diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..5f0ff04 --- /dev/null +++ b/db/db.go @@ -0,0 +1,84 @@ +package db + +import ( + "database/sql" + "fmt" + "log" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +type Database struct { + db *sql.DB +} + +func Open(dbPath string) (*Database, error) { + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, fmt.Errorf("failed to create database directory: %w", err) + } + + sqlDB, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("failed to set journal mode: %w", err) + } + if _, err := sqlDB.Exec("PRAGMA foreign_keys=ON"); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("failed to enable foreign keys: %w", err) + } + + d := &Database{db: sqlDB} + if err := d.migrate(); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("failed to run migrations: %w", err) + } + + log.Printf("Database opened: %s", dbPath) + return d, nil +} + +func (d *Database) Close() error { + return d.db.Close() +} + +func (d *Database) migrate() error { + migrations := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT, + display_name TEXT, + given_name TEXT, + surname TEXT, + password_hash TEXT, + auth_source TEXT NOT NULL DEFAULT 'local', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT UNIQUE NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_at DATETIME + )`, + `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash)`, + } + + for _, m := range migrations { + if _, err := d.db.Exec(m); err != nil { + return fmt.Errorf("migration failed: %w\nSQL: %s", err, m) + } + } + + return nil +} diff --git a/db/tokens.go b/db/tokens.go new file mode 100644 index 0000000..1f3e4d4 --- /dev/null +++ b/db/tokens.go @@ -0,0 +1,113 @@ +package db + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "time" +) + +var ( + ErrTokenNotFound = errors.New("refresh token not found or expired") + ErrTokenRevoked = errors.New("refresh token has been revoked") +) + +type RefreshToken struct { + ID int64 + UserID int64 + TokenHash string + ExpiresAt time.Time + CreatedAt time.Time + RevokedAt *time.Time +} + +func GenerateRefreshToken() (raw string, hash string, err error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", "", fmt.Errorf("failed to generate token: %w", err) + } + raw = hex.EncodeToString(b) + hash = hashToken(raw) + return raw, hash, nil +} + +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} + +func (d *Database) StoreRefreshToken(userID int64, tokenHash string, expiresAt time.Time) error { + _, err := d.db.Exec( + `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)`, + userID, tokenHash, expiresAt, + ) + if err != nil { + return fmt.Errorf("failed to store refresh token: %w", err) + } + return nil +} + +func (d *Database) ValidateRefreshToken(rawToken string) (*RefreshToken, error) { + h := hashToken(rawToken) + + var rt RefreshToken + var revokedAt sql.NullTime + err := d.db.QueryRow( + `SELECT id, user_id, token_hash, expires_at, created_at, revoked_at + FROM refresh_tokens WHERE token_hash = ?`, h, + ).Scan(&rt.ID, &rt.UserID, &rt.TokenHash, &rt.ExpiresAt, &rt.CreatedAt, &revokedAt) + + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrTokenNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to validate token: %w", err) + } + + if revokedAt.Valid { + d.RevokeAllUserTokens(rt.UserID) + return nil, ErrTokenRevoked + } + + if time.Now().After(rt.ExpiresAt) { + return nil, ErrTokenNotFound + } + + return &rt, nil +} + +func (d *Database) RevokeRefreshToken(rawToken string) error { + h := hashToken(rawToken) + _, err := d.db.Exec( + `UPDATE refresh_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE token_hash = ? AND revoked_at IS NULL`, + h, + ) + if err != nil { + return fmt.Errorf("failed to revoke token: %w", err) + } + return nil +} + +func (d *Database) RevokeAllUserTokens(userID int64) error { + _, err := d.db.Exec( + `UPDATE refresh_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL`, + userID, + ) + if err != nil { + return fmt.Errorf("failed to revoke user tokens: %w", err) + } + return nil +} + +func (d *Database) CleanupExpiredTokens() error { + _, err := d.db.Exec( + `DELETE FROM refresh_tokens WHERE expires_at < CURRENT_TIMESTAMP OR revoked_at IS NOT NULL`, + ) + if err != nil { + return fmt.Errorf("failed to cleanup tokens: %w", err) + } + return nil +} diff --git a/db/users.go b/db/users.go new file mode 100644 index 0000000..af9e950 --- /dev/null +++ b/db/users.go @@ -0,0 +1,210 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "time" + + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrUserNotFound = errors.New("user not found") + ErrUsernameExists = errors.New("username already exists") + ErrInvalidCredentials = errors.New("invalid username or password") +) + +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + Email string `json:"email,omitempty"` + DisplayName string `json:"display_name,omitempty"` + GivenName string `json:"given_name,omitempty"` + Surname string `json:"surname,omitempty"` + PasswordHash string `json:"-"` + AuthSource string `json:"auth_source"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return "", fmt.Errorf("failed to hash password: %w", err) + } + return string(hash), nil +} + +func (d *Database) CreateUser(username, email, displayName, password string) (*User, error) { + hash, err := HashPassword(password) + if err != nil { + return nil, err + } + + result, err := d.db.Exec( + `INSERT INTO users (username, email, display_name, password_hash, auth_source) + VALUES (?, ?, ?, ?, 'local')`, + username, email, displayName, hash, + ) + if err != nil { + if isUniqueConstraintError(err) { + return nil, ErrUsernameExists + } + return nil, fmt.Errorf("failed to create user: %w", err) + } + + id, _ := result.LastInsertId() + return d.GetUserByID(id) +} + +func (d *Database) GetUserByID(id int64) (*User, error) { + user := &User{} + err := d.db.QueryRow( + `SELECT id, username, COALESCE(email,''), COALESCE(display_name,''), + COALESCE(given_name,''), COALESCE(surname,''), + COALESCE(password_hash,''), auth_source, created_at, updated_at + FROM users WHERE id = ?`, id, + ).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, + &user.GivenName, &user.Surname, &user.PasswordHash, + &user.AuthSource, &user.CreatedAt, &user.UpdatedAt) + + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + return user, nil +} + +func (d *Database) GetUserByUsername(username string) (*User, error) { + user := &User{} + err := d.db.QueryRow( + `SELECT id, username, COALESCE(email,''), COALESCE(display_name,''), + COALESCE(given_name,''), COALESCE(surname,''), + COALESCE(password_hash,''), auth_source, created_at, updated_at + FROM users WHERE username = ?`, username, + ).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, + &user.GivenName, &user.Surname, &user.PasswordHash, + &user.AuthSource, &user.CreatedAt, &user.UpdatedAt) + + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + return user, nil +} + +func (d *Database) AuthenticateUser(username, password string) (*User, error) { + user, err := d.GetUserByUsername(username) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return nil, ErrInvalidCredentials + } + return nil, err + } + + if user.AuthSource != "local" { + return nil, ErrInvalidCredentials + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return nil, ErrInvalidCredentials + } + + return user, nil +} + +func (d *Database) ListUsers() ([]User, error) { + rows, err := d.db.Query( + `SELECT id, username, COALESCE(email,''), COALESCE(display_name,''), + COALESCE(given_name,''), COALESCE(surname,''), + auth_source, created_at, updated_at + FROM users ORDER BY username`, + ) + if err != nil { + return nil, fmt.Errorf("failed to list users: %w", err) + } + defer rows.Close() + + var users []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.DisplayName, + &u.GivenName, &u.Surname, &u.AuthSource, &u.CreatedAt, &u.UpdatedAt); err != nil { + return nil, fmt.Errorf("failed to scan user: %w", err) + } + users = append(users, u) + } + + if users == nil { + users = []User{} + } + return users, rows.Err() +} + +func (d *Database) UpdateUser(id int64, email, displayName *string, password *string) (*User, error) { + if password != nil { + hash, err := HashPassword(*password) + if err != nil { + return nil, err + } + _, err = d.db.Exec( + `UPDATE users SET email = COALESCE(?, email), display_name = COALESCE(?, display_name), + password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + email, displayName, hash, id, + ) + if err != nil { + return nil, fmt.Errorf("failed to update user: %w", err) + } + } else { + _, err := d.db.Exec( + `UPDATE users SET email = COALESCE(?, email), display_name = COALESCE(?, display_name), + updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + email, displayName, id, + ) + if err != nil { + return nil, fmt.Errorf("failed to update user: %w", err) + } + } + + return d.GetUserByID(id) +} + +func (d *Database) DeleteUser(id int64) error { + result, err := d.db.Exec(`DELETE FROM users WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("failed to delete user: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return ErrUserNotFound + } + return nil +} + +func (d *Database) UserCount() (int64, error) { + var count int64 + err := d.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count) + return count, err +} + +func isUniqueConstraintError(err error) bool { + return err != nil && (contains(err.Error(), "UNIQUE constraint failed") || contains(err.Error(), "unique constraint")) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/docs/adr/ADR-001-local-user-authentication.md b/docs/adr/ADR-001-local-user-authentication.md new file mode 100644 index 0000000..8bf9ba0 --- /dev/null +++ b/docs/adr/ADR-001-local-user-authentication.md @@ -0,0 +1,406 @@ +# ADR-001: Local User Authentication + +**Status:** Proposed +**Date:** 2026-04-03 +**Deciders:** OpenVox View maintainers + +--- + +## Context + +OpenVox View currently has no authentication. All API endpoints under `/api/v1/` are publicly accessible to anyone who can reach the server. This is a significant security gap, especially for the Puppet CA endpoints (`/api/v1/ca/*`) which allow signing and revoking certificates. + +The application is a single Go binary that embeds the Vue 3 frontend. Adding authentication requires: +- A place to store user accounts +- A session mechanism that survives page reloads without forcing re-login +- A toggle so trusted-network deployments can run without auth + +--- + +## Decisions + +| Question | Decision | Rationale | +|---|---|---| +| User storage | **SQLite** | Enables user management API/UI; single-file DB fits the single-binary model | +| Token strategy | **Short-lived access JWT + long-lived refresh token** | Avoids frequent re-logins while keeping access tokens small and revocable | +| Authorization model | **Single admin role** (authenticated = full access) | No RBAC needed at this stage | +| Auth optional | **Config toggle** (`auth.enabled`) | Trusted-network deployments should not be forced to manage users | + +--- + +## Token Strategy + +| Token | TTL | Storage | Purpose | +|---|---|---|---| +| **Access token** (JWT) | `15m` (configurable) | Frontend `localStorage` via Pinia | Sent as `Authorization: Bearer` on every API request | +| **Refresh token** (opaque) | `30d` (configurable) | SQLite `refresh_tokens` table + frontend `localStorage` | Exchanges for a new access token; revocable server-side | + +**Refresh flow:** +1. Access token expires → frontend receives `401` +2. Axios response interceptor automatically calls `POST /api/v1/auth/refresh` with the refresh token +3. If valid: new access + refresh tokens returned, request retried transparently +4. If invalid/expired: auth store cleared, redirect to login + +This gives users persistent sessions without re-login until the refresh token expires (30 days) or is explicitly revoked. + +--- + +## Database Design + +### SQLite file location + +Configurable via `auth.db_path` (default: `data/openvoxview.db` relative to binary). Created on startup if absent. + +### Schema + +```sql +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT, + display_name TEXT, + password_hash TEXT NOT NULL, -- bcrypt hash + auth_source TEXT NOT NULL DEFAULT 'local', -- 'local' | 'saml' (ADR-002) + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT UNIQUE NOT NULL, -- SHA-256 of the raw token + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_at DATETIME -- NULL = active +); + +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash); +``` + +`password_hash` is `NULL` for SAML-provisioned users (see ADR-002). The `auth_source` column distinguishes local vs. SAML users and is shared with ADR-002. + +### First-run bootstrapping + +A `--create-admin` CLI flag creates the first user interactively if the DB is empty: + +``` +openvoxview --create-admin +Username: admin +Password: **** +Confirm: **** +Admin user created. +``` + +On startup, if `auth.enabled = true` and the users table is empty, the server logs a prominent warning. + +--- + +## Architecture Changes + +### New Go Package: `db/` + +``` +db/ +├── db.go # Open/migrate SQLite, exported DB handle +├── users.go # User CRUD +└── tokens.go # Refresh token CRUD + cleanup +``` + +Uses `database/sql` with raw SQL (no ORM). Schema migrations run automatically on startup via versioned `CREATE TABLE IF NOT EXISTS` statements. + +**New Go dependency:** `modernc.org/sqlite` — pure Go SQLite driver, no CGO required. Critical for cross-platform builds (Linux/FreeBSD, AMD64/ARM/ARM64) without a C toolchain. + +### `config/config.go` — New auth config section + +```go +type AuthConfig struct { + Enabled bool `mapstructure:"enabled"` + JwtSecret string `mapstructure:"jwt_secret"` + AccessTokenTTL int `mapstructure:"access_token_ttl_minutes"` // default: 15 + RefreshTokenTTL int `mapstructure:"refresh_token_ttl_days"` // default: 30 + DbPath string `mapstructure:"db_path"` // default: "data/openvoxview.db" +} +``` + +Environment variable equivalents: +- `OPENVOXVIEW_AUTH_ENABLED` +- `OPENVOXVIEW_AUTH_JWT_SECRET` +- `OPENVOXVIEW_AUTH_DB_PATH` + +### `handler/auth.go` — Auth endpoints + +``` +POST /api/v1/auth/login – local credential check → access + refresh tokens +POST /api/v1/auth/refresh – exchange refresh token → new access + refresh tokens (rotation) +POST /api/v1/auth/logout – revoke current refresh token +GET /api/v1/auth/me – return current user profile from token claims +``` + +**User management endpoints** (also in `handler/auth.go`, require auth): + +``` +GET /api/v1/auth/users – list all users +POST /api/v1/auth/users – create user +PUT /api/v1/auth/users/:id – update user (email, display_name, password) +DELETE /api/v1/auth/users/:id – delete user +``` + +Login response body: + +```json +{ + "Data": { + "access_token": "eyJ...", + "refresh_token": "opaque-random-64-bytes-hex", + "expires_in": 900 + } +} +``` + +### `middleware/auth.go` — JWT validation + +```go +func JWTAuthMiddleware(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + if !cfg.Auth.Enabled { + c.Next() + return + } + token := extractBearerToken(c) + claims, err := validateToken(token, cfg.Auth.JwtSecret) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, NewErrorResponse(err)) + return + } + c.Set("user_id", claims.Subject) // user ID as string + c.Set("username", claims["username"]) + c.Next() + } +} +``` + +### JWT claims + +```json +{ + "sub": "42", + "username": "alice", + "email": "alice@example.com", + "display_name": "Alice Smith", + "iat": 1234567890, + "exp": 1234568790 +} +``` + +### `main.go` — Route structure + +```go +// Public (no auth) +r.POST("/api/v1/auth/login", authHandler.Login) +r.POST("/api/v1/auth/refresh", authHandler.Refresh) +r.GET("/api/v1/version", ...) + +// Protected +api := r.Group("/api/v1/") +api.Use(middleware.JWTAuthMiddleware(cfg)) +{ + api.GET("meta", ...) + api.GET("view/*", ...) + api.POST("pdb/*", ...) + api.POST("ca/*", ...) + api.POST("auth/logout", authHandler.Logout) + api.GET("auth/me", authHandler.Me) + api.GET("auth/users", authHandler.ListUsers) + api.POST("auth/users", authHandler.CreateUser) + api.PUT("auth/users/:id", authHandler.UpdateUser) + api.DELETE("auth/users/:id", authHandler.DeleteUser) +} +``` + +--- + +## Frontend Changes + +### New: `ui/src/stores/auth.ts` (Pinia, persisted) + +```typescript +export const useAuthStore = defineStore('auth', () => { + const accessToken = ref(null) + const refreshToken = ref(null) + const username = ref(null) + const email = ref(null) + const displayName = ref(null) + const expiresAt = ref(null) // Unix seconds + + const isAuthenticated = computed(() => + !!accessToken.value && !!expiresAt.value && Date.now() < expiresAt.value * 1000 + ) + + function setAuth(data: LoginResponse) { /* populate from response */ } + function clearAuth() { /* null everything */ } + + return { accessToken, refreshToken, username, email, displayName, + expiresAt, isAuthenticated, setAuth, clearAuth } +}, { persist: true }) +``` + +### New: `ui/src/pages/LoginPage.vue` + +- Centered Quasar `q-card` with logo +- Username + password inputs +- "Login" button → `Backend.login()` → `auth.setAuth()` → redirect to Dashboard +- Error message on `401` +- Conditionally shows "Login with SSO" button when SAML is enabled (see ADR-002) + +### New: `ui/src/layouts/AuthLayout.vue` + +Minimal layout (no sidebar/header) used only for the login page. + +### `ui/src/router/routes.ts` — Guard + login route + +```typescript +// Add before existing routes +{ + path: '/login', + component: () => import('layouts/AuthLayout.vue'), + children: [{ name: 'Login', path: '', component: () => import('pages/LoginPage.vue') }], + meta: { public: true } +} + +// Navigation guard +router.beforeEach((to) => { + const auth = useAuthStore() + if (!to.meta?.public && !auth.isAuthenticated) { + return { name: 'Login' } + } +}) +``` + +### `ui/src/boot/axios.ts` — Interceptors + +```typescript +// REQUEST: inject access token +api.interceptors.request.use((config) => { + const auth = useAuthStore() + if (auth.accessToken) config.headers.Authorization = `Bearer ${auth.accessToken}` + return config +}) + +// RESPONSE: silent token refresh on 401, then retry once +let isRefreshing = false +let failedQueue: Array<{ resolve: Function; reject: Function }> = [] + +api.interceptors.response.use(null, async (error: AxiosError) => { + if (error.response?.status !== 401) return Promise.reject(error) + + const auth = useAuthStore() + if (!auth.refreshToken) { auth.clearAuth(); router.push({ name: 'Login' }); return } + + if (isRefreshing) { + return new Promise((resolve, reject) => failedQueue.push({ resolve, reject })) + } + + isRefreshing = true + try { + const res = await Backend.refreshToken(auth.refreshToken) + auth.setAuth(res.data.Data) + failedQueue.forEach(p => p.resolve()) + return api.request(error.config!) // retry original request + } catch { + auth.clearAuth() + router.push({ name: 'Login' }) + } finally { + isRefreshing = false + failedQueue = [] + } +}) +``` + +### `ui/src/client/backend.ts` — New auth methods + +```typescript +login(username: string, password: string): AxiosPromise> +refreshToken(token: string): AxiosPromise> +logout(): AxiosPromise> +getMe(): AxiosPromise> +getUsers(): AxiosPromise> +createUser(data: CreateUserRequest): AxiosPromise> +updateUser(id: number, data: UpdateUserRequest): AxiosPromise> +deleteUser(id: number): AxiosPromise> +``` + +### `ui/src/layouts/MainLayout.vue` — User menu + +Add to toolbar: avatar showing `displayName`, dropdown with "My Account" and "Logout". Logout calls `Backend.logout()` then `auth.clearAuth()` then navigates to Login. + +--- + +## Configuration Example + +```yaml +auth: + enabled: true + jwt_secret: "minimum-32-character-random-secret-here" + access_token_ttl_minutes: 15 + refresh_token_ttl_days: 30 + db_path: "data/openvoxview.db" +``` + +--- + +## Security Considerations + +- **JWT secret**: Warn loudly at startup if shorter than 32 characters or if using a placeholder value +- **Refresh token rotation**: Each use of a refresh token invalidates it and issues a new one. If an old token is presented (reuse detection), revoke the entire token family for that user +- **bcrypt cost**: Use cost factor 12 (current recommended default) +- **Rate limiting**: `POST /api/v1/auth/login` — 5 attempts per IP per minute (in-memory, resets on restart) +- **HTTPS**: Access and refresh tokens in `localStorage` are only safe over HTTPS; document this requirement +- **DB file permissions**: The SQLite file contains password hashes; restrict to `0600` +- **Self-delete guard**: Prevent a user from deleting their own account via the management API + +--- + +## Consequences + +### Positive +- SQLite enables a full user management API (list/create/update/delete users) now and a management UI later +- Refresh token rotation gives persistent sessions with server-side revocability +- No external infrastructure; SQLite file ships alongside the binary +- Auth-disabled mode requires zero config changes for existing deployments + +### Negative / Trade-offs +- Adds `modernc.org/sqlite` as a significant new dependency (~8 MB to binary size) +- SQLite file must be on persistent storage (not suitable for stateless container without a volume mount) +- In-memory rate limiter resets on restart (acceptable for v1) + +### Future upgrade path +- Add a user management page in the Vue frontend +- Add TOTP/MFA per user +- Replace in-memory rate limiter with persistent counter in SQLite +- Add audit log table for login/logout/user-change events + +--- + +## Implementation Checklist + +**Backend** +- [ ] Add `modernc.org/sqlite` to `go.mod` +- [ ] Add `golang-jwt/jwt/v5` to `go.mod` +- [ ] Create `db/` package: `db.go`, `users.go`, `tokens.go` +- [ ] Add `AuthConfig` to `config/config.go` with Viper bindings and env vars +- [ ] Create `middleware/auth.go` (JWT validation, auth-disabled passthrough) +- [ ] Create `handler/auth.go` (login, refresh, logout, me, user CRUD) +- [ ] Modify `main.go`: open DB, init handlers, register routes (public vs. protected) +- [ ] Add `--create-admin` CLI flag +- [ ] Update `CONFIGURATION.md` + +**Frontend** +- [ ] Create `ui/src/stores/auth.ts` +- [ ] Create `ui/src/pages/LoginPage.vue` +- [ ] Create `ui/src/layouts/AuthLayout.vue` +- [ ] Update `ui/src/router/routes.ts` (login route + `beforeEach` guard) +- [ ] Update `ui/src/boot/axios.ts` (request injector + 401 refresh interceptor) +- [ ] Update `ui/src/client/backend.ts` (auth methods) +- [ ] Update `ui/src/client/models.ts` (LoginResponse, UserProfile, etc.) +- [ ] Update `ui/src/layouts/MainLayout.vue` (user menu + logout) diff --git a/docs/adr/ADR-002-saml-authentication.md b/docs/adr/ADR-002-saml-authentication.md new file mode 100644 index 0000000..96c54a4 --- /dev/null +++ b/docs/adr/ADR-002-saml-authentication.md @@ -0,0 +1,412 @@ +# ADR-002: SAML Authentication (EntraID / ADFS) + +**Status:** Proposed +**Date:** 2026-04-03 +**Deciders:** OpenVox View maintainers +**Depends on:** ADR-001 (shares SQLite DB, JWT issuance, refresh token mechanism, auth middleware, and frontend auth store) + +--- + +## Context + +Enterprise deployments need to integrate with corporate identity providers. Managing local user accounts (ADR-001) is operationally burdensome at scale and doesn't integrate with corporate MFA, conditional access policies, or account lifecycle management. + +Both **Microsoft Entra ID (Azure AD)** and **ADFS** are targeted. SAML 2.0 is universally supported by both; OIDC/OAuth2 support in older ADFS versions (pre-4.0) is unreliable. + +The SAML implementation builds directly on ADR-001's infrastructure: after the SAML assertion is validated, the same JWT access + refresh token pair is issued. The frontend auth flow is identical regardless of login method. + +--- + +## Decisions + +| Question | Decision | Rationale | +|---|---|---| +| Protocol | **SAML 2.0** | Universal EntraID + ADFS support; `crewjam/saml` is battle-tested | +| User provisioning | **Auto-provision on first SAML login** into SQLite `users` table | Eliminates manual account creation; attributes flow from IdP | +| SAML attributes mapped | **email, givenname, surname, displayname** | User profile fields; no group/role mapping needed | +| Token handoff SPA→SP | **Query param `?token=` on redirect** | Simple, consistent with single-binary model; frontend clears URL immediately | +| IdP metadata | **Fetched from URL at startup, cached** | Picks up IdP cert rotations automatically | + +--- + +## SAML Attribute Mapping + +The following SAML claim URIs are used. These are the standard Microsoft claim types emitted by both EntraID and ADFS by default: + +| App field | SAML claim URI | +|---|---| +| `email` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` | +| `given_name` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname` | +| `surname` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname` | +| `display_name` | `http://schemas.microsoft.com/identity/claims/displayname` | +| `username` (identity) | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` (email used as username for SAML users) | + +All attribute URIs are configurable to support non-Microsoft IdPs. + +--- + +## Authentication Flow + +``` +Browser OpenVox View (SP) EntraID / ADFS (IdP) + | | | + | GET /dashboard | | + | (not authenticated) | | + |<-- 302 /login --------------| | + | | | + | Click "Login with SSO" | | + | GET /api/v1/auth/saml/login>| | + | |-- SAMLRequest (redirect) ---> | + |<-- 302 to IdP login --------| | + | | | + | User authenticates (+ MFA) ---------------------------> | + | | | + |<-- POST /api/v1/auth/saml/acs with SAMLResponse ------------| + | | | + | |-- validate signature | + | |-- check audience/conditions | + | |-- extract attributes | + | |-- upsert user in SQLite | + | |-- issue access + refresh JWT | + |<-- 302 /login?token=&refresh= | + | | | + | Frontend stores tokens | | + | Clears URL params | | + | Redirect to Dashboard | | +``` + +--- + +## SQLite Schema Additions (extends ADR-001) + +No new tables needed. The `users` table from ADR-001 already has `auth_source` and `email`/`display_name` columns. SAML users are distinguished by `auth_source = 'saml'` and have `password_hash = NULL`. + +Two additional columns are added to `users`: + +```sql +ALTER TABLE users ADD COLUMN given_name TEXT; +ALTER TABLE users ADD COLUMN surname TEXT; +``` + +On each SAML login, the user record is **upserted** (insert or update on `email` conflict) so that profile changes in the IdP propagate automatically. + +--- + +## Architecture Changes + +### New Go Dependency + +| Package | Purpose | +|---|---| +| `github.com/crewjam/saml` | SAML 2.0 SP implementation (signature verification, metadata generation, assertion parsing). Used in production by Grafana, HashiCorp Vault. | + +### `config/config.go` — SAML sub-section under `AuthConfig` + +```go +type AuthConfig struct { + // ... existing ADR-001 fields ... + Saml SamlConfig `mapstructure:"saml"` +} + +type SamlConfig struct { + Enabled bool `mapstructure:"enabled"` + IdpMetadataURL string `mapstructure:"idp_metadata_url"` // preferred: live URL + IdpMetadataFile string `mapstructure:"idp_metadata_file"` // fallback: local file + SpEntityID string `mapstructure:"sp_entity_id"` // e.g. https://openvoxview.example.com + SpAcsURL string `mapstructure:"sp_acs_url"` // e.g. https://openvoxview.example.com/api/v1/auth/saml/acs + SpCertFile string `mapstructure:"sp_cert_file"` + SpKeyFile string `mapstructure:"sp_key_file"` + + // Attribute claim URIs (defaults to Microsoft standard claims) + AttrEmail string `mapstructure:"attr_email"` + AttrGivenName string `mapstructure:"attr_given_name"` + AttrSurname string `mapstructure:"attr_surname"` + AttrDisplayName string `mapstructure:"attr_display_name"` +} +``` + +Environment variable equivalents: +- `OPENVOXVIEW_AUTH_SAML_ENABLED` +- `OPENVOXVIEW_AUTH_SAML_IDP_METADATA_URL` +- `OPENVOXVIEW_AUTH_SAML_SP_ENTITY_ID` +- `OPENVOXVIEW_AUTH_SAML_SP_ACS_URL` +- `OPENVOXVIEW_AUTH_SAML_SP_CERT_FILE` +- `OPENVOXVIEW_AUTH_SAML_SP_KEY_FILE` + +### `middleware/saml.go` — SP initialization + +```go +func NewSamlServiceProvider(cfg *config.SamlConfig) (*saml.ServiceProvider, error) { + // 1. Load SP cert + key from files + // 2. Fetch/parse IdP metadata from URL or file + // 3. Return configured saml.ServiceProvider +} +``` + +The `ServiceProvider` instance is created once at startup and injected into `AuthHandler`. If `idp_metadata_url` is set, metadata is re-fetched every hour in the background to pick up IdP certificate rotations. + +### `handler/auth.go` — SAML endpoints (adds to ADR-001) + +```go +// GET /api/v1/auth/saml/metadata +// Returns SP metadata XML — paste this into EntraID/ADFS app registration +func (h *AuthHandler) SamlMetadata(c *gin.Context) + +// GET /api/v1/auth/saml/login +// Builds SAML AuthnRequest, redirects browser to IdP +func (h *AuthHandler) SamlLogin(c *gin.Context) + +// POST /api/v1/auth/saml/acs (Assertion Consumer Service — registered in IdP) +// Validates SAMLResponse, upserts user, issues JWT pair, redirects to frontend +func (h *AuthHandler) SamlACS(c *gin.Context) +``` + +**ACS handler logic:** + +```go +func (h *AuthHandler) SamlACS(c *gin.Context) { + // 1. Parse and validate SAML response (crewjam/saml handles sig, audience, timing) + assertion, err := h.sp.ParseResponse(c.Request, []string{relayState}) + + // 2. Extract attributes + email := getAttr(assertion, h.cfg.Auth.Saml.AttrEmail) + givenName := getAttr(assertion, h.cfg.Auth.Saml.AttrGivenName) + surname := getAttr(assertion, h.cfg.Auth.Saml.AttrSurname) + displayName := getAttr(assertion, h.cfg.Auth.Saml.AttrDisplayName) + + // 3. Upsert user in SQLite + user, err := h.db.UpsertSamlUser(email, givenName, surname, displayName) + + // 4. Issue access + refresh tokens (same function as local login) + tokens, err := h.issueTokenPair(user) + + // 5. Redirect frontend with tokens + redirectURL := fmt.Sprintf("/login?token=%s&refresh=%s", tokens.AccessToken, tokens.RefreshToken) + c.Redirect(http.StatusFound, redirectURL) +} +``` + +### `main.go` — SAML routes (public, outside JWT middleware) + +```go +// These must be public — browser redirects, no token available yet +if cfg.Auth.Saml.Enabled { + r.GET("/api/v1/auth/saml/metadata", authHandler.SamlMetadata) + r.GET("/api/v1/auth/saml/login", authHandler.SamlLogin) + r.POST("/api/v1/auth/saml/acs", authHandler.SamlACS) +} +``` + +### `handler/core.go` — Meta response update + +Add `SamlEnabled bool` to the `/api/v1/meta` response so the frontend knows whether to show the SSO button: + +```go +type MetaResponse struct { + CaEnabled bool `json:"CaEnabled"` + CaReadOnly bool `json:"CaReadOnly"` + UnreportedHours uint64 `json:"UnreportedHours"` + StripPathPrefix string `json:"StripPathPrefix"` + SamlEnabled bool `json:"SamlEnabled"` // NEW +} +``` + +--- + +## Frontend Changes + +No new dependencies. SAML is a browser redirect — the SPA only needs minor additions. + +### `ui/src/client/models.ts` + +```typescript +export interface ApiMeta { + CaEnabled: boolean + CaReadOnly: boolean + UnreportedHours: number + StripPathPrefix: string + SamlEnabled: boolean // NEW +} +``` + +### `ui/src/pages/LoginPage.vue` — SSO button + token extraction + +```typescript +onMounted(async () => { + // Case 1: returning from SAML ACS with tokens in URL + if (route.query.token) { + auth.setAuth({ + access_token: route.query.token as string, + refresh_token: route.query.refresh as string, + // decode expiry from JWT claims + }) + await router.replace({ name: 'Dashboard' }) + return + } + + // Case 2: load meta to decide whether to show SSO button + try { + const meta = await Backend.getMeta() + samlEnabled.value = meta.data.Data.SamlEnabled + } catch { /* ignore, auth might be disabled */ } +}) + +function samlLogin() { + // Full page navigation to trigger SAML redirect chain + window.location.href = '/api/v1/auth/saml/login' +} +``` + +```vue + +``` + +No other frontend files need changes — the auth store, axios interceptors, router guard, and token refresh logic from ADR-001 all work unchanged for SAML-authenticated users. + +--- + +## SP Certificate + +The SAML SP requires an X.509 certificate for the SP metadata (and optionally signing AuthnRequests). This is independent of PuppetDB TLS certificates. + +A `--generate-saml-cert` CLI flag generates a self-signed cert: + +``` +openvoxview --generate-saml-cert --output-dir /etc/openvoxview/ +Generated: /etc/openvoxview/saml-sp.crt +Generated: /etc/openvoxview/saml-sp.key +``` + +The `.crt` content is embedded in the SP metadata XML that the IdP reads. Copy-paste it into the IdP's "SAML Signing Certificate" field during app registration. + +--- + +## IdP Setup Guides + +### Microsoft Entra ID + +1. **Azure Portal** → Enterprise Applications → New Application → "Create your own application" (non-gallery) +2. **Single Sign-On** → SAML +3. **Basic SAML Configuration:** + - Identifier (Entity ID): value of `sp_entity_id` + - Reply URL (ACS): value of `sp_acs_url` + - Sign on URL: `https:///api/v1/auth/saml/login` +4. **Attributes & Claims** — ensure these are emitted (they are by default): + - `emailaddress` → `user.mail` + - `givenname` → `user.givenname` + - `surname` → `user.surname` + - `displayname` → `user.displayname` +5. **SAML Certificates** → copy the **App Federation Metadata Url** → use as `idp_metadata_url` +6. **Users and Groups** → assign the users/groups who should have access + +### ADFS + +1. **ADFS Management** → Trust Relationships → Relying Party Trusts → Add Relying Party Trust +2. Choose **"Import data about the relying party from a URL"** → enter `https:///api/v1/auth/saml/metadata` +3. **Issuance Transform Rules** → Add Rule → "Send LDAP Attributes as Claims": + | LDAP Attribute | Outgoing Claim Type | + |---|---| + | E-Mail-Addresses | E-Mail Address | + | Given-Name | Given Name | + | Surname | Surname | + | Display-Name | `http://schemas.microsoft.com/identity/claims/displayname` | +4. No relying party token encryption needed unless required by policy + +--- + +## Configuration Example + +```yaml +auth: + enabled: true + jwt_secret: "minimum-32-character-random-secret-here" + access_token_ttl_minutes: 15 + refresh_token_ttl_days: 30 + db_path: "data/openvoxview.db" + + # Local users still work alongside SAML — useful for a break-glass admin + users: [] # managed via API + + saml: + enabled: true + idp_metadata_url: "https://login.microsoftonline.com//federationmetadata/2007-06/federationmetadata.xml" + sp_entity_id: "https://openvoxview.example.com" + sp_acs_url: "https://openvoxview.example.com/api/v1/auth/saml/acs" + sp_cert_file: "/etc/openvoxview/saml-sp.crt" + sp_key_file: "/etc/openvoxview/saml-sp.key" + # Attribute URIs below are the Microsoft defaults — only change if your IdP differs + attr_email: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" + attr_given_name: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname" + attr_surname: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname" + attr_display_name: "http://schemas.microsoft.com/identity/claims/displayname" +``` + +--- + +## Security Considerations + +- **Assertion validation**: `crewjam/saml` validates signature, issuer, audience restriction, `NotBefore`/`NotOnOrAfter`, and `InResponseTo` (replay prevention) by default — do not bypass these checks +- **ACS URL must be HTTPS**: EntraID and ADFS reject HTTP ACS URLs; document this as a hard requirement +- **Token query params**: `?token=` and `?refresh=` are short-lived (access token TTL) and cleared from the URL immediately by the SPA. They will appear in browser history — a known trade-off of this approach +- **RelayState**: Managed by `crewjam/saml` to prevent CSRF on the ACS endpoint +- **SAML + local coexistence**: Operators can have both enabled simultaneously. A "break-glass" local admin account is recommended so access is not lost if the IdP is unreachable +- **IdP metadata refresh**: Re-fetch metadata hourly to automatically handle IdP signing certificate rotations without a restart +- **Attribute assertion**: If a SAML login arrives with no `email` attribute, reject it with a clear error — email is the user's primary identity key + +--- + +## Consequences + +### Positive +- Users authenticate with corporate credentials; inherits MFA and conditional access from the IdP +- Account lifecycle (onboarding/offboarding) managed centrally in the IdP +- Profile attributes (name, email) stay current via upsert on each login +- Local accounts remain available as a fallback + +### Negative / Trade-offs +- SAML app registration in EntraID/ADFS requires IdP admin access — operational overhead for initial setup +- `crewjam/saml` adds a significant dependency; XML parsing is a known attack surface (mitigated by the library's signature enforcement) +- `?token=` in URL is a minor security imperfection; acceptable for v1 +- ACS endpoint must be reachable by the IdP (requires public HTTPS URL or network path from IdP to SP) +- SAML metadata fetch on startup adds a network dependency; implement graceful degradation if URL unreachable (fall back to `idp_metadata_file`, warn loudly) + +### Future upgrade path +- Implement SAML Single Logout (SLO) so logout in OpenVox View propagates to the IdP session +- Replace `?token=` handoff with HttpOnly short-lived cookie for improved security +- Add SAML group → role mapping when RBAC is introduced +- Add OIDC/OAuth2 as an alternative IdP protocol for non-ADFS cloud environments + +--- + +## Implementation Checklist + +**Backend** +- [ ] Add `github.com/crewjam/saml` to `go.mod` +- [ ] Add `SamlConfig` struct to `config/config.go` with Viper bindings +- [ ] Add `given_name` / `surname` columns to `users` table migration in `db/users.go` +- [ ] Add `UpsertSamlUser()` to `db/users.go` +- [ ] Create `middleware/saml.go` — `NewSamlServiceProvider()`, hourly metadata refresh goroutine +- [ ] Add `SamlMetadata`, `SamlLogin`, `SamlACS` to `handler/auth.go` +- [ ] Add `SamlEnabled` to `MetaResponse` in `handler/core.go` (or `handler/view.go`) +- [ ] Register SAML routes in `main.go` (public, conditional on `cfg.Auth.Saml.Enabled`) +- [ ] Add `--generate-saml-cert` CLI flag +- [ ] Update `CONFIGURATION.md` with SAML config options and IdP setup guides + +**Frontend** +- [ ] Add `SamlEnabled` to `ApiMeta` in `ui/src/client/models.ts` +- [ ] Add SAML token extraction + SSO button to `ui/src/pages/LoginPage.vue` +- [ ] No other frontend changes needed (auth store, interceptors, and router guard from ADR-001 are protocol-agnostic) diff --git a/go.mod b/go.mod index 4bfc6d2..e5ca17c 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,11 @@ module github.com/sebastianrakel/openvoxview go 1.24 -require github.com/gin-gonic/gin v1.11.0 +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.2.2 + modernc.org/sqlite v1.37.1 +) require ( github.com/fsnotify/fsnotify v1.9.0 // indirect diff --git a/handler/auth.go b/handler/auth.go new file mode 100644 index 0000000..a440ac0 --- /dev/null +++ b/handler/auth.go @@ -0,0 +1,308 @@ +package handler + +import ( + "errors" + "log" + "net/http" + "strconv" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/sebastianrakel/openvoxview/config" + "github.com/sebastianrakel/openvoxview/db" + "github.com/sebastianrakel/openvoxview/middleware" +) + +type AuthHandler struct { + config *config.Config + database *db.Database + rateLimiter *rateLimiter +} + +func NewAuthHandler(config *config.Config, database *db.Database) *AuthHandler { + return &AuthHandler{ + config: config, + database: database, + rateLimiter: newRateLimiter(), + } +} + +type loginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type refreshRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +type logoutRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +type loginResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` +} + +type createUserRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required,min=8"` + Email string `json:"email"` + DisplayName string `json:"display_name"` +} + +type updateUserRequest struct { + Email *string `json:"email"` + DisplayName *string `json:"display_name"` + Password *string `json:"password"` +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(err)) + return + } + + clientIP := c.ClientIP() + if !h.rateLimiter.allow(clientIP) { + c.AbortWithStatusJSON(http.StatusTooManyRequests, NewErrorResponse(errors.New("too many login attempts, try again later"))) + return + } + + user, err := h.database.AuthenticateUser(req.Username, req.Password) + if err != nil { + if errors.Is(err, db.ErrInvalidCredentials) { + c.AbortWithStatusJSON(http.StatusUnauthorized, NewErrorResponse(errors.New("invalid username or password"))) + return + } + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + tokens, err := h.issueTokenPair(user) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + log.Printf("[AUDIT] User logged in: %s", user.Username) + c.JSON(http.StatusOK, NewSuccessResponse(tokens)) +} + +func (h *AuthHandler) Refresh(c *gin.Context) { + var req refreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(err)) + return + } + + rt, err := h.database.ValidateRefreshToken(req.RefreshToken) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, NewErrorResponse(errors.New("invalid or expired refresh token"))) + return + } + + // Revoke old token (rotation) + h.database.RevokeRefreshToken(req.RefreshToken) + + user, err := h.database.GetUserByID(rt.UserID) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, NewErrorResponse(errors.New("user not found"))) + return + } + + tokens, err := h.issueTokenPair(user) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + c.JSON(http.StatusOK, NewSuccessResponse(tokens)) +} + +func (h *AuthHandler) Logout(c *gin.Context) { + var req logoutRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(err)) + return + } + + h.database.RevokeRefreshToken(req.RefreshToken) + + username, _ := c.Get("username") + log.Printf("[AUDIT] User logged out: %v", username) + + c.JSON(http.StatusOK, NewSuccessResponse(nil)) +} + +func (h *AuthHandler) Me(c *gin.Context) { + userIDStr, _ := c.Get("user_id") + userID, err := strconv.ParseInt(userIDStr.(string), 10, 64) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + user, err := h.database.GetUserByID(userID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, NewErrorResponse(err)) + return + } + + c.JSON(http.StatusOK, NewSuccessResponse(user)) +} + +func (h *AuthHandler) ListUsers(c *gin.Context) { + users, err := h.database.ListUsers() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + c.JSON(http.StatusOK, NewSuccessResponse(users)) +} + +func (h *AuthHandler) CreateUser(c *gin.Context) { + var req createUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(err)) + return + } + + user, err := h.database.CreateUser(req.Username, req.Email, req.DisplayName, req.Password) + if err != nil { + if errors.Is(err, db.ErrUsernameExists) { + c.AbortWithStatusJSON(http.StatusConflict, NewErrorResponse(err)) + return + } + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + log.Printf("[AUDIT] User created: %s (by %v)", req.Username, c.GetString("username")) + c.JSON(http.StatusCreated, NewSuccessResponse(user)) +} + +func (h *AuthHandler) UpdateUser(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(errors.New("invalid user id"))) + return + } + + var req updateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(err)) + return + } + + user, err := h.database.UpdateUser(id, req.Email, req.DisplayName, req.Password) + if err != nil { + if errors.Is(err, db.ErrUserNotFound) { + c.AbortWithStatusJSON(http.StatusNotFound, NewErrorResponse(err)) + return + } + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + log.Printf("[AUDIT] User updated: id=%d (by %v)", id, c.GetString("username")) + c.JSON(http.StatusOK, NewSuccessResponse(user)) +} + +func (h *AuthHandler) DeleteUser(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(errors.New("invalid user id"))) + return + } + + // Prevent self-deletion + currentUserIDStr, _ := c.Get("user_id") + currentUserID, _ := strconv.ParseInt(currentUserIDStr.(string), 10, 64) + if id == currentUserID { + c.AbortWithStatusJSON(http.StatusForbidden, NewErrorResponse(errors.New("cannot delete your own account"))) + return + } + + if err := h.database.DeleteUser(id); err != nil { + if errors.Is(err, db.ErrUserNotFound) { + c.AbortWithStatusJSON(http.StatusNotFound, NewErrorResponse(err)) + return + } + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + log.Printf("[AUDIT] User deleted: id=%d (by %v)", id, c.GetString("username")) + c.JSON(http.StatusOK, NewSuccessResponse(nil)) +} + +func (h *AuthHandler) issueTokenPair(user *db.User) (*loginResponse, error) { + accessToken, expiresAt, err := middleware.GenerateAccessToken( + user.ID, user.Username, user.Email, user.DisplayName, + h.config.Auth.JwtSecret, h.config.Auth.AccessTokenTTL, + ) + if err != nil { + return nil, err + } + + rawRefresh, hashRefresh, err := db.GenerateRefreshToken() + if err != nil { + return nil, err + } + + refreshExpiry := time.Now().Add(time.Duration(h.config.Auth.RefreshTokenTTL) * 24 * time.Hour) + if err := h.database.StoreRefreshToken(user.ID, hashRefresh, refreshExpiry); err != nil { + return nil, err + } + + expiresIn := expiresAt - time.Now().Unix() + + return &loginResponse{ + AccessToken: accessToken, + RefreshToken: rawRefresh, + ExpiresIn: expiresIn, + }, nil +} + +// Simple in-memory rate limiter: 5 attempts per IP per minute +type rateLimiter struct { + mu sync.Mutex + attempts map[string][]time.Time +} + +func newRateLimiter() *rateLimiter { + return &rateLimiter{ + attempts: make(map[string][]time.Time), + } +} + +func (rl *rateLimiter) allow(ip string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := time.Now() + window := now.Add(-1 * time.Minute) + + // Clean old entries + valid := make([]time.Time, 0) + for _, t := range rl.attempts[ip] { + if t.After(window) { + valid = append(valid, t) + } + } + + if len(valid) >= 5 { + rl.attempts[ip] = valid + return false + } + + rl.attempts[ip] = append(valid, now) + return true +} diff --git a/main.go b/main.go index a5887ca..9e2ea8d 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,22 @@ package main import ( + "bufio" + "crypto/rand" + "encoding/hex" "fmt" "io/fs" "log" "net/http" + "os" "strings" + "time" "github.com/gin-gonic/gin" "github.com/sebastianrakel/openvoxview/config" + "github.com/sebastianrakel/openvoxview/db" "github.com/sebastianrakel/openvoxview/handler" + "github.com/sebastianrakel/openvoxview/middleware" ) var ( @@ -27,11 +34,53 @@ func main() { panic(err) } + // Handle --create-admin before starting the server + if config.CreateAdmin() { + runCreateAdmin(cfg) + return + } + log.Printf("LISTEN: %s", cfg.Listen) log.Printf("PORT: %d", cfg.Port) log.Printf("PUPPETDB_ADDRESS: %s", cfg.GetPuppetDbAddress()) log.Printf("TRUSTED_PROXIES: %#v", cfg.TrustedProxies) + // Initialize auth database if auth is enabled + var database *db.Database + if cfg.Auth.Enabled { + if cfg.Auth.JwtSecret == "" { + cfg.Auth.JwtSecret = generateRandomSecret() + log.Printf("WARNING: No jwt_secret configured. A random secret was generated. Tokens will not survive restarts. Set auth.jwt_secret in your config.") + } + if len(cfg.Auth.JwtSecret) < 32 { + log.Printf("WARNING: jwt_secret is shorter than 32 characters. This is insecure for production use.") + } + + database, err = db.Open(cfg.Auth.DbPath) + if err != nil { + log.Fatalf("Failed to open auth database: %v", err) + } + defer database.Close() + + count, _ := database.UserCount() + if count == 0 { + log.Printf("WARNING: Auth is enabled but no users exist. Use --create-admin to create the first user.") + } + + // Start periodic token cleanup + go func() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for range ticker.C { + database.CleanupExpiredTokens() + } + }() + + log.Printf("AUTH: enabled (db: %s)", cfg.Auth.DbPath) + } else { + log.Printf("AUTH: disabled") + } + r := gin.Default() r.NoRoute(func(c *gin.Context) { @@ -54,39 +103,48 @@ func main() { caEnabled := cfg.PuppetCA.Host != "" + // Public auth endpoints (no JWT required) + if cfg.Auth.Enabled { + authHandler := handler.NewAuthHandler(cfg, database) + r.POST("/api/v1/auth/login", authHandler.Login) + r.POST("/api/v1/auth/refresh", authHandler.Refresh) + } + + // Public endpoints (no JWT required) + r.GET("/api/v1/version", func(c *gin.Context) { + type versionResponse struct { + Version string + } + c.JSON(http.StatusOK, handler.NewSuccessResponse(versionResponse{Version: VERSION})) + }) + + r.GET("/api/v1/meta", func(c *gin.Context) { + type metaResponse struct { + CaEnabled bool + CaReadOnly bool + UnreportedHours uint64 + StripPathPrefix string + AuthEnabled bool + } + + response := metaResponse{ + CaEnabled: caEnabled, + CaReadOnly: cfg.PuppetCA.ReadOnly, + UnreportedHours: cfg.UnreportedHours, + StripPathPrefix: cfg.StripPathPrefix, + AuthEnabled: cfg.Auth.Enabled, + } + + c.JSON(http.StatusOK, handler.NewSuccessResponse(response)) + }) + pdbHandler := handler.NewPdbHandler(cfg) viewHandler := handler.NewViewHandler(cfg) api := r.Group("/api/v1/") + api.Use(middleware.JWTAuthMiddleware(cfg)) { - api.GET("meta", func(c *gin.Context) { - type metaResponse struct { - CaEnabled bool - CaReadOnly bool - UnreportedHours uint64 - StripPathPrefix string - } - - response := metaResponse{ - CaEnabled: caEnabled, - CaReadOnly: cfg.PuppetCA.ReadOnly, - UnreportedHours: cfg.UnreportedHours, - StripPathPrefix: cfg.StripPathPrefix, - } - - c.JSON(http.StatusOK, handler.NewSuccessResponse(response)) - }) - api.GET("version", func(c *gin.Context) { - type versionResponse struct { - Version string - } - response := versionResponse{ - Version: VERSION, - } - - c.JSON(http.StatusOK, handler.NewSuccessResponse(response)) - }) view := api.Group("view") { view.GET("node_overview", viewHandler.NodesOverview) @@ -104,6 +162,20 @@ func main() { pdb.GET("fact-names", pdbHandler.PdbGetFactNames) pdb.POST("event-counts", pdbHandler.PdbGetEventCounts) } + + // Auth management endpoints (require auth) + if cfg.Auth.Enabled { + authHandler := handler.NewAuthHandler(cfg, database) + auth := api.Group("auth") + { + auth.POST("logout", authHandler.Logout) + auth.GET("me", authHandler.Me) + auth.GET("users", authHandler.ListUsers) + auth.POST("users", authHandler.CreateUser) + auth.PUT("users/:id", authHandler.UpdateUser) + auth.DELETE("users/:id", authHandler.DeleteUser) + } + } } if caEnabled { @@ -133,3 +205,55 @@ func AllowCORS(c *gin.Context) { c.Next() } + +func runCreateAdmin(cfg *config.Config) { + database, err := db.Open(cfg.Auth.DbPath) + if err != nil { + log.Fatalf("Failed to open database: %v", err) + } + defer database.Close() + + reader := bufio.NewReader(os.Stdin) + + fmt.Print("Username: ") + username, _ := reader.ReadString('\n') + username = strings.TrimSpace(username) + if username == "" { + log.Fatal("Username cannot be empty") + } + + fmt.Print("Email (optional): ") + email, _ := reader.ReadString('\n') + email = strings.TrimSpace(email) + + fmt.Print("Display Name (optional): ") + displayName, _ := reader.ReadString('\n') + displayName = strings.TrimSpace(displayName) + + fmt.Print("Password: ") + password, _ := reader.ReadString('\n') + password = strings.TrimSpace(password) + if len(password) < 8 { + log.Fatal("Password must be at least 8 characters") + } + + fmt.Print("Confirm Password: ") + confirm, _ := reader.ReadString('\n') + confirm = strings.TrimSpace(confirm) + if password != confirm { + log.Fatal("Passwords do not match") + } + + user, err := database.CreateUser(username, email, displayName, password) + if err != nil { + log.Fatalf("Failed to create user: %v", err) + } + + fmt.Printf("Admin user created: %s (id: %d)\n", user.Username, user.ID) +} + +func generateRandomSecret() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..52545ea --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,103 @@ +package middleware + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/sebastianrakel/openvoxview/config" + "github.com/sebastianrakel/openvoxview/handler" +) + +type UserClaims struct { + Username string `json:"username"` + Email string `json:"email,omitempty"` + DisplayName string `json:"display_name,omitempty"` + jwt.RegisteredClaims +} + +func GenerateAccessToken(userID int64, username, email, displayName, secret string, ttlMinutes int) (string, int64, error) { + expiresAt := time.Now().Add(time.Duration(ttlMinutes) * time.Minute) + + claims := UserClaims{ + Username: username, + Email: email, + DisplayName: displayName, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: strconv.FormatInt(userID, 10), + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(expiresAt), + Issuer: "openvoxview", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(secret)) + if err != nil { + return "", 0, fmt.Errorf("failed to sign token: %w", err) + } + + return signed, expiresAt.Unix(), nil +} + +func JWTAuthMiddleware(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + if !cfg.Auth.Enabled { + c.Next() + return + } + + tokenString := extractBearerToken(c) + if tokenString == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, handler.NewErrorResponse(errors.New("authorization required"))) + return + } + + claims, err := validateToken(tokenString, cfg.Auth.JwtSecret) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, handler.NewErrorResponse(errors.New("invalid or expired token"))) + return + } + + c.Set("user_id", claims.Subject) + c.Set("username", claims.Username) + c.Next() + } +} + +func extractBearerToken(c *gin.Context) string { + auth := c.GetHeader("Authorization") + if auth == "" { + return "" + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { + return "" + } + return parts[1] +} + +func validateToken(tokenString, secret string) (*UserClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &UserClaims{}, 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(secret), nil + }) + + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(*UserClaims) + if !ok || !token.Valid { + return nil, errors.New("invalid token claims") + } + + return claims, nil +} diff --git a/ui/src/boot/axios.ts b/ui/src/boot/axios.ts index fb2be2a..44a78ce 100644 --- a/ui/src/boot/axios.ts +++ b/ui/src/boot/axios.ts @@ -1,7 +1,8 @@ import { defineBoot } from '#q-app/wrappers'; -import axios, {type AxiosError, type AxiosInstance} from 'axios'; +import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'; import { Notify } from 'quasar'; -import {type ErrorResponse} from "src/client/models"; +import { type ErrorResponse } from 'src/client/models'; +import { useAuthStore } from 'stores/auth'; declare module 'vue' { interface ComponentCustomProperties { @@ -10,37 +11,116 @@ declare module 'vue' { } } +const api = axios.create({ baseURL: process.env.VUE_APP_BACKEND_BASE_ADDRESS || '' }); -const api = axios.create({ baseURL: process.env.VUE_APP_BACKEND_BASE_ADDRESS || ''}); - -export default defineBoot(({ app }) => { - // for use inside Vue files (Options API) through this.$axios and this.$api - api.interceptors.response.use(function (response) { - // Any status code that lie within the range of 2xx cause this function to trigger - // Do something with response data - return response; - }, function (error: AxiosError) { - if (error.response && error.status != 400) { - console.log('Cached Error: ', error.response.data.Error); - Notify.create({ - message: error.response.data.Error ?? error.message, - color: 'negative', - multiLine: true, - closeBtn: true, - }) +let isRefreshing = false; +let failedQueue: Array<{ + resolve: (config: InternalAxiosRequestConfig) => void; + reject: (error: unknown) => void; +}> = []; + +function processQueue(error: unknown) { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); } + }); + failedQueue = []; +} - return Promise.reject(error); +export default defineBoot(({ app, router }) => { + // Request interceptor: inject Authorization header + api.interceptors.request.use((config) => { + const auth = useAuthStore(); + if (auth.accessToken) { + config.headers.Authorization = `Bearer ${auth.accessToken}`; + } + return config; }); + // Response interceptor: handle errors and token refresh + api.interceptors.response.use( + function (response) { + return response; + }, + async function (error: AxiosError) { + const originalRequest = error.config; - app.config.globalProperties.$axios = axios; - // ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form) - // so you won't necessarily have to import axios in each vue file + // Handle 401 with silent token refresh + if ( + error.response?.status === 401 && + originalRequest && + !originalRequest.url?.includes('/auth/login') && + !originalRequest.url?.includes('/auth/refresh') + ) { + const auth = useAuthStore(); + + if (!auth.refreshToken) { + auth.clearAuth(); + void router.push({ name: 'Login' }); + return Promise.reject(error); + } + + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ + resolve: () => { + // Re-add the updated auth header + const authStore = useAuthStore(); + if (originalRequest.headers && authStore.accessToken) { + originalRequest.headers.Authorization = `Bearer ${authStore.accessToken}`; + } + resolve(api(originalRequest)); + }, + reject, + }); + }); + } + isRefreshing = true; + + try { + const { default: Backend } = await import('src/client/backend'); + const res = await Backend.refreshToken(auth.refreshToken); + auth.setAuth(res.data.Data); + + // Retry queued requests + failedQueue.forEach((prom) => { + prom.resolve(originalRequest); + }); + failedQueue = []; + + // Retry original request + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${auth.accessToken}`; + } + return api(originalRequest); + } catch (refreshError: unknown) { + processQueue(refreshError); + auth.clearAuth(); + void router.push({ name: 'Login' }); + return Promise.reject(refreshError instanceof Error ? refreshError : new Error('Token refresh failed')); + } finally { + isRefreshing = false; + } + } + + // Show notification for non-400, non-401 errors + if (error.response && error.response.status !== 400 && error.response.status !== 401) { + Notify.create({ + message: error.response.data.Error ?? error.message, + color: 'negative', + multiLine: true, + closeBtn: true, + }); + } + + return Promise.reject(error); + }, + ); + + app.config.globalProperties.$axios = axios; app.config.globalProperties.$api = api; - // ^ ^ ^ this will allow you to use this.$api (for Vue Options API form) - // so you can easily perform requests against your app's API }); export { api }; diff --git a/ui/src/client/backend.ts b/ui/src/client/backend.ts index 037b83c..a74d1ac 100644 --- a/ui/src/client/backend.ts +++ b/ui/src/client/backend.ts @@ -1,6 +1,6 @@ import { api } from 'boot/axios'; import type { AxiosPromise } from 'axios'; -import type { ApiMeta, ApiVersion, BaseResponse } from 'src/client/models'; +import type { ApiMeta, ApiVersion, BaseResponse, LoginResponse, UserProfile, CreateUserRequest, UpdateUserRequest } from 'src/client/models'; import type PqlQuery from 'src/puppet/query-builder'; import type { ApiPredefinedView, @@ -91,6 +91,38 @@ class Backend { cleanCertificate(name: string): AxiosPromise> { return api.delete(`/api/v1/ca/status/${name}`); } + + login(username: string, password: string): AxiosPromise> { + return api.post('/api/v1/auth/login', { username, password }); + } + + refreshToken(token: string): AxiosPromise> { + return api.post('/api/v1/auth/refresh', { refresh_token: token }); + } + + logout(refreshToken: string): AxiosPromise> { + return api.post('/api/v1/auth/logout', { refresh_token: refreshToken }); + } + + getMe(): AxiosPromise> { + return api.get('/api/v1/auth/me'); + } + + getUsers(): AxiosPromise> { + return api.get('/api/v1/auth/users'); + } + + createUser(data: CreateUserRequest): AxiosPromise> { + return api.post('/api/v1/auth/users', data); + } + + updateUser(id: number, data: UpdateUserRequest): AxiosPromise> { + return api.put(`/api/v1/auth/users/${id}`, data); + } + + deleteUser(id: number): AxiosPromise> { + return api.delete(`/api/v1/auth/users/${id}`); + } } export default new Backend(); diff --git a/ui/src/client/models.ts b/ui/src/client/models.ts index f788a92..d283dd5 100644 --- a/ui/src/client/models.ts +++ b/ui/src/client/models.ts @@ -11,8 +11,40 @@ export interface ApiMeta { CaReadOnly: boolean UnreportedHours: number StripPathPrefix: string + AuthEnabled: boolean } export interface ApiVersion { Version: string; } + +export interface LoginResponse { + access_token: string + refresh_token: string + expires_in: number +} + +export interface UserProfile { + id: number + username: string + email: string + display_name: string + given_name: string + surname: string + auth_source: string + created_at: string + updated_at: string +} + +export interface CreateUserRequest { + username: string + password: string + email?: string + display_name?: string +} + +export interface UpdateUserRequest { + email?: string + display_name?: string + password?: string +} diff --git a/ui/src/layouts/AuthLayout.vue b/ui/src/layouts/AuthLayout.vue new file mode 100644 index 0000000..bf35919 --- /dev/null +++ b/ui/src/layouts/AuthLayout.vue @@ -0,0 +1,7 @@ + diff --git a/ui/src/layouts/MainLayout.vue b/ui/src/layouts/MainLayout.vue index 455fc73..77b19df 100644 --- a/ui/src/layouts/MainLayout.vue +++ b/ui/src/layouts/MainLayout.vue @@ -23,7 +23,35 @@ v-model="settings.darkMode" color="positive" /> -
{{ version }}
+
{{ version }}
+ + + + + + {{ auth.displayName || auth.username }} + + + + {{ auth.email }} + + + + + + + + Logout + + + + @@ -119,8 +147,10 @@ + + diff --git a/ui/src/router/index.ts b/ui/src/router/index.ts index 28ca9e8..eded240 100644 --- a/ui/src/router/index.ts +++ b/ui/src/router/index.ts @@ -7,6 +7,7 @@ import { } from 'vue-router'; import routes from './routes'; +import { useAuthStore } from 'stores/auth'; /* * If not building with SSR mode, you can @@ -32,5 +33,21 @@ export default defineRouter(function (/* { store, ssrContext } */) { history: createHistory(process.env.VUE_ROUTER_BASE), }); + Router.beforeEach((to) => { + const auth = useAuthStore(); + // If auth is not enabled, allow all routes + if (auth.authEnabled === false) { + return; + } + // Allow public routes + if (to.meta?.public) { + return; + } + // Redirect to login if not authenticated + if (!auth.isAuthenticated) { + return { name: 'Login' }; + } + }); + return Router; }); diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts index e10ade1..0465629 100644 --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@ -1,6 +1,18 @@ import { type RouteRecordRaw } from 'vue-router'; const routes: RouteRecordRaw[] = [ + { + path: '/login', + component: () => import('layouts/AuthLayout.vue'), + children: [ + { + name: 'Login', + path: '', + component: () => import('pages/LoginPage.vue'), + meta: { public: true }, + }, + ], + }, { path: '/', redirect: { name: 'Dashboard' }, diff --git a/ui/src/stores/auth.ts b/ui/src/stores/auth.ts new file mode 100644 index 0000000..1f8d7ec --- /dev/null +++ b/ui/src/stores/auth.ts @@ -0,0 +1,77 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; +import type { LoginResponse } from 'src/client/models'; + +export const useAuthStore = defineStore( + 'auth', + () => { + const accessToken = ref(null); + const refreshToken = ref(null); + const username = ref(null); + const email = ref(null); + const displayName = ref(null); + const expiresAt = ref(null); + const authEnabled = ref(null); + + const isAuthenticated = computed(() => { + if (authEnabled.value === false) return true; + return ( + !!accessToken.value && + !!expiresAt.value && + Date.now() < expiresAt.value * 1000 + ); + }); + + const needsRefresh = computed(() => { + if (!accessToken.value || !expiresAt.value) return false; + // Token expires within 60 seconds + return Date.now() > (expiresAt.value - 60) * 1000; + }); + + function setAuth(data: LoginResponse) { + accessToken.value = data.access_token; + refreshToken.value = data.refresh_token; + expiresAt.value = Math.floor(Date.now() / 1000) + data.expires_in; + + // Decode username from JWT payload + try { + const parts = data.access_token.split('.'); + const payload = JSON.parse(atob(parts[1] ?? '')); + username.value = payload.username || null; + email.value = payload.email || null; + displayName.value = payload.display_name || null; + } catch { + // If decoding fails, keep existing values + } + } + + function clearAuth() { + accessToken.value = null; + refreshToken.value = null; + username.value = null; + email.value = null; + displayName.value = null; + expiresAt.value = null; + } + + function setAuthEnabled(enabled: boolean) { + authEnabled.value = enabled; + } + + return { + accessToken, + refreshToken, + username, + email, + displayName, + expiresAt, + authEnabled, + isAuthenticated, + needsRefresh, + setAuth, + clearAuth, + setAuthEnabled, + }; + }, + { persist: true }, +); From c56f4e0fcb22176b0e6dad06476d21dd10a7a1e5 Mon Sep 17 00:00:00 2001 From: vitorio92 Date: Fri, 3 Apr 2026 12:46:45 +0200 Subject: [PATCH 02/24] go mod tidy --- go.mod | 10 +++++++++- go.sum | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index e5ca17c..9354037 100644 --- a/go.mod +++ b/go.mod @@ -9,11 +9,15 @@ require ( ) require ( + github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect @@ -21,6 +25,10 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + modernc.org/libc v1.65.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) require ( @@ -44,7 +52,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.41.0 // indirect + golang.org/x/crypto v0.41.0 golang.org/x/net v0.43.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect diff --git a/go.sum b/go.sum index 79e7540..7cb1677 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE 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/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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -31,9 +33,15 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -51,6 +59,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -59,6 +69,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE= github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= +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/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= @@ -96,8 +108,14 @@ golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= @@ -105,6 +123,8 @@ golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -113,3 +133,27 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= +modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= +modernc.org/fileutil v1.3.1/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/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= +modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= +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.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= +modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= +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= From 6146ee970c44691df9dd30b7d7a8c32f2a177416 Mon Sep 17 00:00:00 2001 From: vitorio92 Date: Fri, 3 Apr 2026 14:02:28 +0200 Subject: [PATCH 03/24] Fix import cycle between handler and middleware packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit middleware/auth.go imported handler for NewErrorResponse, creating handler → middleware → handler cycle. Replaced with a local errorResponse helper that produces the same JSON envelope. Co-Authored-By: Claude Sonnet 4.6 --- middleware/auth.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/middleware/auth.go b/middleware/auth.go index 52545ea..1a3eccb 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -11,7 +11,6 @@ import ( "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/sebastianrakel/openvoxview/config" - "github.com/sebastianrakel/openvoxview/handler" ) type UserClaims struct { @@ -54,13 +53,13 @@ func JWTAuthMiddleware(cfg *config.Config) gin.HandlerFunc { tokenString := extractBearerToken(c) if tokenString == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, handler.NewErrorResponse(errors.New("authorization required"))) + c.AbortWithStatusJSON(http.StatusUnauthorized, errorResponse("authorization required")) return } claims, err := validateToken(tokenString, cfg.Auth.JwtSecret) if err != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, handler.NewErrorResponse(errors.New("invalid or expired token"))) + c.AbortWithStatusJSON(http.StatusUnauthorized, errorResponse("invalid or expired token")) return } @@ -70,6 +69,13 @@ func JWTAuthMiddleware(cfg *config.Config) gin.HandlerFunc { } } +func errorResponse(msg string) gin.H { + return gin.H{ + "Timestamp": time.Now().Unix(), + "Error": msg, + } +} + func extractBearerToken(c *gin.Context) string { auth := c.GetHeader("Authorization") if auth == "" { From bcb0b8195aca43abcf44934e8f8c881bc848421c Mon Sep 17 00:00:00 2001 From: vitorio92 Date: Fri, 3 Apr 2026 14:55:36 +0200 Subject: [PATCH 04/24] Add authentication test plan Manual test checklist covering login flows, session persistence, token security, user management API, auth-disabled mode, CLI, and UI elements. Co-Authored-By: Claude Sonnet 4.6 --- docs/testing/auth-test-plan.md | 56 ++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/testing/auth-test-plan.md diff --git a/docs/testing/auth-test-plan.md b/docs/testing/auth-test-plan.md new file mode 100644 index 0000000..4e19e54 --- /dev/null +++ b/docs/testing/auth-test-plan.md @@ -0,0 +1,56 @@ +# Authentication Test Plan (ADR-001) + +Manual test plan for local user authentication. + +## Authentication Flow + +- [ ] **Login with valid credentials** — should redirect to Dashboard +- [ ] **Login with wrong password** — should show "Invalid username or password" +- [ ] **Login with non-existent user** — should show "Invalid username or password" (same message, no user enumeration) +- [ ] **Login with empty fields** — should show validation error +- [ ] **Rate limiting** — try 6 rapid failed logins from the same IP, 6th should return "Too many login attempts" + +## Session Persistence + +- [ ] **Page reload after login** — should stay authenticated (not redirected to login) +- [ ] **Close browser tab and reopen** — should still be logged in (refresh token in localStorage) +- [ ] **Wait 15+ minutes** (or set `access_token_ttl_minutes: 1` for testing) — next API call should silently refresh the token without redirecting to login + +## Logout + +- [ ] **Click user icon → Logout** — should redirect to login page +- [ ] **After logout, press browser back button** — should not access protected content (redirected to login) +- [ ] **After logout, reuse old access token via curl** — should get 401 + +## Token Security + +- [ ] **Access API without token**: `curl http:///api/v1/view/node_overview` — should return 401 +- [ ] **Access API with valid token**: `curl -H "Authorization: Bearer " http:///api/v1/view/node_overview` — should return data +- [ ] **Access API with garbage token**: `curl -H "Authorization: Bearer abc123" ...` — should return 401 +- [ ] **Public endpoints work without token**: `curl http:///api/v1/version` and `curl http:///api/v1/meta` — should return data + +## User Management API + +- [ ] **List users**: `GET /api/v1/auth/users` — should return the admin user +- [ ] **Create user**: `POST /api/v1/auth/users` with `{"username":"testuser","password":"test12345678","email":"test@example.com"}` — should return 201 +- [ ] **Create duplicate username** — should return 409 Conflict +- [ ] **Create user with short password** (< 8 chars) — should return 400 +- [ ] **Update user**: `PUT /api/v1/auth/users/2` with `{"display_name":"Test User"}` — should update +- [ ] **Delete user**: `DELETE /api/v1/auth/users/2` — should delete +- [ ] **Self-delete guard**: try deleting your own user ID — should return 403 "cannot delete your own account" +- [ ] **Login as newly created user** — should work + +## Auth Disabled Mode + +- [ ] **Set `auth.enabled: false`**, restart — all pages accessible without login, no user menu shown, no login redirect + +## CLI + +- [ ] **`--create-admin`** — creates user interactively, confirm it shows in `GET /api/v1/auth/users` +- [ ] **`--create-admin` with mismatched passwords** — should abort with error + +## UI Elements + +- [ ] **User menu visible** in top-right toolbar when logged in (account icon) +- [ ] **User menu shows username/display name and email** +- [ ] **All existing pages still work** after login: Dashboard, Nodes, Facts, Reports, Query, CA (if enabled), predefined views From 6d5bc25545cee29963f0e0789b2ae552193fe6c9 Mon Sep 17 00:00:00 2001 From: vitorio92 Date: Fri, 3 Apr 2026 15:10:18 +0200 Subject: [PATCH 05/24] Add ADR-003: User management UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend-only feature — adds a user management page with q-table, create/edit dialogs, and delete confirmation. All backend API endpoints already exist from ADR-001. Co-Authored-By: Claude Sonnet 4.6 --- docs/adr/ADR-003-user-management-ui.md | 144 +++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 docs/adr/ADR-003-user-management-ui.md diff --git a/docs/adr/ADR-003-user-management-ui.md b/docs/adr/ADR-003-user-management-ui.md new file mode 100644 index 0000000..f6d93b0 --- /dev/null +++ b/docs/adr/ADR-003-user-management-ui.md @@ -0,0 +1,144 @@ +# ADR-003: User Management UI + +**Status:** Proposed +**Date:** 2026-04-03 +**Deciders:** OpenVox View maintainers +**Depends on:** ADR-001 (backend user CRUD API, auth store, auth-enabled detection) + +--- + +## Context + +ADR-001 introduced local user authentication with a full backend CRUD API for user management (`GET/POST/PUT/DELETE /api/v1/auth/users`). The frontend client methods (`Backend.getUsers()`, `createUser()`, `updateUser()`, `deleteUser()`) and TypeScript models (`UserProfile`, `CreateUserRequest`, `UpdateUserRequest`) also already exist. + +Currently, user management is only possible via CLI (`--create-admin`) or direct API calls with curl. Administrators need a UI to manage users without leaving the browser. + +--- + +## Decision + +Add a **User Management page** at route `/users` within the existing `MainLayout`, following the same patterns used by the CA Overview page (q-table, q-dialog for confirmations, Notify for feedback). The page is only visible in the sidebar when `auth.enabled` is `true`. + +No backend changes are required — all API endpoints already exist. + +--- + +## UI Design + +### Page: `UserManagementPage.vue` + +A single page with: + +1. **Users table** (q-table) showing all users +2. **"Add User" button** in the table header — opens a create dialog +3. **Row actions**: Edit and Delete buttons per row +4. **Self-delete protection**: Delete button disabled on the current user's row + +### Table columns + +| Column | Field | Sortable | Notes | +|---|---|---|---| +| Username | `username` | Yes | Primary identifier | +| Display Name | `display_name` | Yes | | +| Email | `email` | Yes | | +| Auth Source | `auth_source` | Yes | "local" or "saml" (future ADR-002) | +| Created | `created_at` | Yes | Formatted date | +| Actions | — | No | Edit / Delete buttons | + +### Create User Dialog + +Quasar `q-dialog` with a form: + +| Field | Type | Validation | +|---|---|---| +| Username | q-input, text | Required | +| Email | q-input, email | Optional | +| Display Name | q-input, text | Optional | +| Password | q-input, password | Required, min 8 chars | +| Confirm Password | q-input, password | Must match Password | + +On submit: `Backend.createUser()` → success notification → reload table. +On 409: show "Username already exists" error. + +### Edit User Dialog + +Same q-dialog, pre-populated with existing values. Username is read-only (not editable after creation). Password fields are optional — leave blank to keep current password. + +On submit: `Backend.updateUser(id, data)` → success notification → reload table. + +### Delete Confirmation + +Quasar `q.dialog()` confirm pattern (same as CA page uses for sign/revoke/clean): + +``` +"Delete user ? This action cannot be undone." +[Cancel] [Delete (red)] +``` + +On confirm: `Backend.deleteUser(id)` → success notification → reload table. +Self-delete: button is disabled with a tooltip "Cannot delete your own account". Backend also enforces this (403). + +--- + +## Architecture Changes + +### Frontend only — no backend changes + +#### New: `ui/src/pages/admin/UserManagementPage.vue` + +Single-file Vue component containing: +- `q-table` with columns definition, pagination, and row template +- Three functions: `loadUsers()`, `openCreateDialog()`, `openEditDialog(user)`, `confirmDelete(user)` +- Create/Edit dialog as inline `q-dialog` with `v-model` toggle (same pattern as other pages) +- Current user ID from `useAuthStore()` to disable self-delete + +#### Update: `ui/src/router/routes.ts` + +Add route under the existing MainLayout children: + +```typescript +{ + name: 'UserManagement', + path: 'users', + component: () => import('pages/admin/UserManagementPage.vue'), +} +``` + +#### Update: `ui/src/layouts/MainLayout.vue` + +Add sidebar menu item, conditionally shown when `auth.authEnabled` is true: + +```vue + + + + + + Users + + +``` + +Placed after the CA menu item in the sidebar. + +--- + +## What Already Exists (no changes needed) + +| Layer | Component | Status | +|---|---|---| +| Backend API | `GET/POST/PUT/DELETE /api/v1/auth/users` | Done (ADR-001) | +| Frontend client | `Backend.getUsers()`, `createUser()`, `updateUser()`, `deleteUser()` | Done (ADR-001) | +| TypeScript models | `UserProfile`, `CreateUserRequest`, `UpdateUserRequest` | Done (ADR-001) | +| Auth store | `useAuthStore()` with user ID for self-delete check | Done (ADR-001) | +| Auth middleware | JWT validation on all user management endpoints | Done (ADR-001) | + +--- + +## Implementation Checklist + +- [ ] Create `ui/src/pages/admin/UserManagementPage.vue` +- [ ] Add `UserManagement` route to `ui/src/router/routes.ts` +- [ ] Add "Users" menu item to `ui/src/layouts/MainLayout.vue` sidebar (conditional on `auth.authEnabled`) +- [ ] Build and verify (`yarn build`, `yarn lint`) +- [ ] Add test cases to `docs/testing/auth-test-plan.md` From 77c5cb18955efedff76b0f17ea963dd002b952f5 Mon Sep 17 00:00:00 2001 From: vitorio92 Date: Fri, 3 Apr 2026 15:10:43 +0200 Subject: [PATCH 06/24] tests successfully for adr-001 done --- docs/testing/auth-test-plan.md | 58 +++++++++++++++++----------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/docs/testing/auth-test-plan.md b/docs/testing/auth-test-plan.md index 4e19e54..1c51c6e 100644 --- a/docs/testing/auth-test-plan.md +++ b/docs/testing/auth-test-plan.md @@ -4,53 +4,53 @@ Manual test plan for local user authentication. ## Authentication Flow -- [ ] **Login with valid credentials** — should redirect to Dashboard -- [ ] **Login with wrong password** — should show "Invalid username or password" -- [ ] **Login with non-existent user** — should show "Invalid username or password" (same message, no user enumeration) -- [ ] **Login with empty fields** — should show validation error -- [ ] **Rate limiting** — try 6 rapid failed logins from the same IP, 6th should return "Too many login attempts" +- [x] **Login with valid credentials** — should redirect to Dashboard +- [x] **Login with wrong password** — should show "Invalid username or password" +- [x] **Login with non-existent user** — should show "Invalid username or password" (same message, no user enumeration) +- [x] **Login with empty fields** — should show validation error +- [x] **Rate limiting** — try 6 rapid failed logins from the same IP, 6th should return "Too many login attempts" ## Session Persistence -- [ ] **Page reload after login** — should stay authenticated (not redirected to login) -- [ ] **Close browser tab and reopen** — should still be logged in (refresh token in localStorage) -- [ ] **Wait 15+ minutes** (or set `access_token_ttl_minutes: 1` for testing) — next API call should silently refresh the token without redirecting to login +- [x] **Page reload after login** — should stay authenticated (not redirected to login) +- [x] **Close browser tab and reopen** — should still be logged in (refresh token in localStorage) +- [x] **Wait 15+ minutes** (or set `access_token_ttl_minutes: 1` for testing) — next API call should silently refresh the token without redirecting to login ## Logout -- [ ] **Click user icon → Logout** — should redirect to login page -- [ ] **After logout, press browser back button** — should not access protected content (redirected to login) -- [ ] **After logout, reuse old access token via curl** — should get 401 +- [x] **Click user icon → Logout** — should redirect to login page +- [x] **After logout, press browser back button** — should not access protected content (redirected to login) +- [x] **After logout, reuse old access token via curl** — should get 401 ## Token Security -- [ ] **Access API without token**: `curl http:///api/v1/view/node_overview` — should return 401 -- [ ] **Access API with valid token**: `curl -H "Authorization: Bearer " http:///api/v1/view/node_overview` — should return data -- [ ] **Access API with garbage token**: `curl -H "Authorization: Bearer abc123" ...` — should return 401 -- [ ] **Public endpoints work without token**: `curl http:///api/v1/version` and `curl http:///api/v1/meta` — should return data +- [x] **Access API without token**: `curl http:///api/v1/view/node_overview` — should return 401 +- [x] **Access API with valid token**: `curl -H "Authorization: Bearer " http:///api/v1/view/node_overview` — should return data +- [x] **Access API with garbage token**: `curl -H "Authorization: Bearer abc123" ...` — should return 401 +- [x] **Public endpoints work without token**: `curl http:///api/v1/version` and `curl http:///api/v1/meta` — should return data ## User Management API -- [ ] **List users**: `GET /api/v1/auth/users` — should return the admin user -- [ ] **Create user**: `POST /api/v1/auth/users` with `{"username":"testuser","password":"test12345678","email":"test@example.com"}` — should return 201 -- [ ] **Create duplicate username** — should return 409 Conflict -- [ ] **Create user with short password** (< 8 chars) — should return 400 -- [ ] **Update user**: `PUT /api/v1/auth/users/2` with `{"display_name":"Test User"}` — should update -- [ ] **Delete user**: `DELETE /api/v1/auth/users/2` — should delete -- [ ] **Self-delete guard**: try deleting your own user ID — should return 403 "cannot delete your own account" -- [ ] **Login as newly created user** — should work +- [x] **List users**: `GET /api/v1/auth/users` — should return the admin user +- [x] **Create user**: `POST /api/v1/auth/users` with `{"username":"testuser","password":"test12345678","email":"test@example.com"}` — should return 201 +- [x] **Create duplicate username** — should return 409 Conflict +- [x] **Create user with short password** (< 8 chars) — should return 400 +- [x] **Update user**: `PUT /api/v1/auth/users/2` with `{"display_name":"Test User"}` — should update +- [x] **Delete user**: `DELETE /api/v1/auth/users/2` — should delete +- [x] **Self-delete guard**: try deleting your own user ID — should return 403 "cannot delete your own account" +- [x] **Login as newly created user** — should work ## Auth Disabled Mode -- [ ] **Set `auth.enabled: false`**, restart — all pages accessible without login, no user menu shown, no login redirect +- [x] **Set `auth.enabled: false`**, restart — all pages accessible without login, no user menu shown, no login redirect ## CLI -- [ ] **`--create-admin`** — creates user interactively, confirm it shows in `GET /api/v1/auth/users` -- [ ] **`--create-admin` with mismatched passwords** — should abort with error +- [x] **`--create-admin`** — creates user interactively, confirm it shows in `GET /api/v1/auth/users` +- [x] **`--create-admin` with mismatched passwords** — should abort with error ## UI Elements -- [ ] **User menu visible** in top-right toolbar when logged in (account icon) -- [ ] **User menu shows username/display name and email** -- [ ] **All existing pages still work** after login: Dashboard, Nodes, Facts, Reports, Query, CA (if enabled), predefined views +- [x] **User menu visible** in top-right toolbar when logged in (account icon) +- [x] **User menu shows username/display name and email** +- [x] **All existing pages still work** after login: Dashboard, Nodes, Facts, Reports, Query, CA (if enabled), predefined views From dc36a2ba20f017f695e3f42d2afdf81f10887c3e Mon Sep 17 00:00:00 2001 From: vitorio92 Date: Fri, 3 Apr 2026 15:15:21 +0200 Subject: [PATCH 07/24] Add user management UI page (ADR-003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New UserManagementPage with q-table listing all users, create/edit dialogs, and delete confirmation. Sidebar menu item shown only when auth is enabled. Includes i18n translations (en-US, de-DE) and test plan updates. No backend changes — uses existing CRUD API from ADR-001. Co-Authored-By: Claude Sonnet 4.6 --- docs/testing/auth-test-plan.md | 36 +++ ui/src/i18n/langs/de-DE.json | 30 +- ui/src/i18n/langs/en-US.json | 30 +- ui/src/layouts/MainLayout.vue | 10 + ui/src/pages/admin/UserManagementPage.vue | 316 ++++++++++++++++++++++ ui/src/router/routes.ts | 5 + 6 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 ui/src/pages/admin/UserManagementPage.vue diff --git a/docs/testing/auth-test-plan.md b/docs/testing/auth-test-plan.md index 1c51c6e..474315d 100644 --- a/docs/testing/auth-test-plan.md +++ b/docs/testing/auth-test-plan.md @@ -54,3 +54,39 @@ Manual test plan for local user authentication. - [x] **User menu visible** in top-right toolbar when logged in (account icon) - [x] **User menu shows username/display name and email** - [x] **All existing pages still work** after login: Dashboard, Nodes, Facts, Reports, Query, CA (if enabled), predefined views + +## User Management UI (ADR-003) + +### Visibility +- [ ] **"Users" menu item visible** in sidebar when auth is enabled +- [ ] **"Users" menu item hidden** when auth is disabled + +### User Table +- [ ] **Users page loads** — shows table with all users, columns: Username, Display Name, Email, Auth Source, Created, Actions +- [ ] **Refresh button** — reloads the user list + +### Create User +- [ ] **Click "Add User"** — opens create dialog with empty fields +- [ ] **Submit with valid data** — user created, success notification, table refreshes +- [ ] **Submit with missing username** — shows "Username is required" error +- [ ] **Submit with short password** (< 8 chars) — shows "Password must be at least 8 characters" +- [ ] **Submit with mismatched passwords** — shows "Passwords do not match" +- [ ] **Submit with duplicate username** — shows "Username already exists" +- [ ] **Cancel button** — closes dialog without creating + +### Edit User +- [ ] **Click edit icon on a user row** — opens dialog pre-populated with user data +- [ ] **Username field is read-only** in edit mode +- [ ] **Update display name / email** — saves, success notification, table refreshes +- [ ] **Change password** — confirm password field appears, saves new password +- [ ] **Leave password blank** — keeps existing password unchanged +- [ ] **Cancel button** — closes dialog without saving + +### Delete User +- [ ] **Click delete icon** — shows confirmation dialog with username +- [ ] **Confirm delete** — user removed, success notification, table refreshes +- [ ] **Cancel delete** — dialog closes, user not deleted +- [ ] **Delete button disabled on own row** — tooltip shows "Cannot delete your own account" + +### i18n +- [ ] **German locale** — all labels, buttons, and messages display in German diff --git a/ui/src/i18n/langs/de-DE.json b/ui/src/i18n/langs/de-DE.json index a82cffb..d84ea1a 100644 --- a/ui/src/i18n/langs/de-DE.json +++ b/ui/src/i18n/langs/de-DE.json @@ -81,5 +81,33 @@ "LABEL_CONFIRM_REVOKE_MESSAGE": "Sind Sie sicher, dass Sie das Zertifikat für {certname} widerrufen möchten?", "LABEL_CLEAN": "Bereinigen", "LABEL_CONFIRM_CLEAN_TITLE": "Zertifikat bereinigen?", - "LABEL_CONFIRM_CLEAN_MESSAGE": "Sind Sie sicher, dass Sie das Zertifikat für {certname} bereinigen (löschen) möchten?" + "LABEL_CONFIRM_CLEAN_MESSAGE": "Sind Sie sicher, dass Sie das Zertifikat für {certname} bereinigen (löschen) möchten?", + + "MENU_USERS": "Benutzer", + "LABEL_USERNAME": "Benutzername", + "LABEL_DISPLAY_NAME": "Anzeigename", + "LABEL_EMAIL": "E-Mail", + "LABEL_AUTH_SOURCE": "Authentifizierungsquelle", + "LABEL_CREATED": "Erstellt", + "LABEL_PASSWORD": "Passwort", + "LABEL_NEW_PASSWORD": "Neues Passwort", + "LABEL_CONFIRM_PASSWORD": "Passwort bestätigen", + "LABEL_PASSWORD_HINT_EDIT": "Leer lassen, um das aktuelle Passwort beizubehalten", + "LABEL_PASSWORD_MIN_LENGTH": "Passwort muss mindestens 8 Zeichen lang sein", + "LABEL_PASSWORDS_NO_MATCH": "Passwörter stimmen nicht überein", + "LABEL_USERNAME_REQUIRED": "Benutzername ist erforderlich", + "LABEL_USERNAME_EXISTS": "Benutzername existiert bereits", + "LABEL_CREATE_USER": "Benutzer erstellen", + "LABEL_EDIT_USER": "Benutzer bearbeiten", + "LABEL_DELETE": "Löschen", + "LABEL_CANNOT_DELETE_SELF": "Eigenes Konto kann nicht gelöscht werden", + "LABEL_CONFIRM_DELETE_USER_TITLE": "Benutzer löschen?", + "LABEL_CONFIRM_DELETE_USER_MESSAGE": "Sind Sie sicher, dass Sie den Benutzer {username} löschen möchten? Dies kann nicht rückgängig gemacht werden.", + "BTN_ADD_USER": "Benutzer hinzufügen", + "BTN_EDIT": "Bearbeiten", + "BTN_SAVE": "Speichern", + "BTN_CREATE": "Erstellen", + "NOTIFICATION_USER_CREATED": "Benutzer erfolgreich erstellt", + "NOTIFICATION_USER_UPDATED": "Benutzer erfolgreich aktualisiert", + "NOTIFICATION_USER_DELETED": "Benutzer erfolgreich gelöscht" } diff --git a/ui/src/i18n/langs/en-US.json b/ui/src/i18n/langs/en-US.json index 79534b0..c210f98 100644 --- a/ui/src/i18n/langs/en-US.json +++ b/ui/src/i18n/langs/en-US.json @@ -81,5 +81,33 @@ "LABEL_CONFIRM_REVOKE_MESSAGE": "Are you sure you want to revoke the certificate for {certname}?", "LABEL_CLEAN": "Clean", "LABEL_CONFIRM_CLEAN_TITLE": "Clean certificate?", - "LABEL_CONFIRM_CLEAN_MESSAGE": "Are you sure you want to clean (delete) the certificate for {certname}?" + "LABEL_CONFIRM_CLEAN_MESSAGE": "Are you sure you want to clean (delete) the certificate for {certname}?", + + "MENU_USERS": "Users", + "LABEL_USERNAME": "Username", + "LABEL_DISPLAY_NAME": "Display Name", + "LABEL_EMAIL": "Email", + "LABEL_AUTH_SOURCE": "Auth Source", + "LABEL_CREATED": "Created", + "LABEL_PASSWORD": "Password", + "LABEL_NEW_PASSWORD": "New Password", + "LABEL_CONFIRM_PASSWORD": "Confirm Password", + "LABEL_PASSWORD_HINT_EDIT": "Leave blank to keep current password", + "LABEL_PASSWORD_MIN_LENGTH": "Password must be at least 8 characters", + "LABEL_PASSWORDS_NO_MATCH": "Passwords do not match", + "LABEL_USERNAME_REQUIRED": "Username is required", + "LABEL_USERNAME_EXISTS": "Username already exists", + "LABEL_CREATE_USER": "Create User", + "LABEL_EDIT_USER": "Edit User", + "LABEL_DELETE": "Delete", + "LABEL_CANNOT_DELETE_SELF": "Cannot delete your own account", + "LABEL_CONFIRM_DELETE_USER_TITLE": "Delete user?", + "LABEL_CONFIRM_DELETE_USER_MESSAGE": "Are you sure you want to delete the user {username}? This cannot be undone.", + "BTN_ADD_USER": "Add User", + "BTN_EDIT": "Edit", + "BTN_SAVE": "Save", + "BTN_CREATE": "Create", + "NOTIFICATION_USER_CREATED": "User created successfully", + "NOTIFICATION_USER_UPDATED": "User updated successfully", + "NOTIFICATION_USER_DELETED": "User deleted successfully" } diff --git a/ui/src/layouts/MainLayout.vue b/ui/src/layouts/MainLayout.vue index 77b19df..0ab0468 100644 --- a/ui/src/layouts/MainLayout.vue +++ b/ui/src/layouts/MainLayout.vue @@ -135,6 +135,16 @@ {{ $t('MENU_CA') }} + + + + + + + + {{ $t('MENU_USERS') }} + + diff --git a/ui/src/pages/admin/UserManagementPage.vue b/ui/src/pages/admin/UserManagementPage.vue new file mode 100644 index 0000000..08febaa --- /dev/null +++ b/ui/src/pages/admin/UserManagementPage.vue @@ -0,0 +1,316 @@ + + + diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts index 0465629..4316c1a 100644 --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@ -68,6 +68,11 @@ const routes: RouteRecordRaw[] = [ path: 'ca', component: () => import('pages/ca/CAOverviewPage.vue'), }, + { + name: 'UserManagement', + path: 'users', + component: () => import('pages/admin/UserManagementPage.vue'), + }, ], }, ]; From 12e79057b4ce3c6903ed4bd25e9c01d7252ba202 Mon Sep 17 00:00:00 2001 From: vitorio92 Date: Fri, 3 Apr 2026 18:57:17 +0200 Subject: [PATCH 08/24] Implement SAML 2.0 authentication (ADR-002) Add SAML SP support using crewjam/saml for EntraID/ADFS integration. Users are auto-provisioned on first SAML login with email as username, and profile attributes (email, given name, surname, display name) are synced on each login. Local and SAML auth coexist for break-glass access. Backend: SamlConfig, UpsertSamlUser, middleware/saml.go (SP init, hourly metadata refresh), SAML endpoints (metadata, login redirect, ACS), --generate-saml-cert CLI flag, SamlEnabled in meta response. Frontend: SSO button on login page (conditional on SamlEnabled), SAML token extraction from redirect params, i18n keys (en/de). Co-Authored-By: Claude Opus 4.6 --- CONFIGURATION.md | 63 ++++++++- config/config.go | 42 +++++- db/users.go | 33 +++++ docs/adr/ADR-002-saml-authentication.md | 2 +- docs/testing/auth-test-plan.md | 24 ++-- go.mod | 5 + go.sum | 24 ++++ handler/auth.go | 112 +++++++++++++++ main.go | 113 +++++++++++++-- middleware/saml.go | 181 ++++++++++++++++++++++++ ui/src/client/models.ts | 1 + ui/src/i18n/langs/de-DE.json | 5 +- ui/src/i18n/langs/en-US.json | 5 +- ui/src/pages/LoginPage.vue | 21 ++- 14 files changed, 600 insertions(+), 31 deletions(-) create mode 100644 middleware/saml.go diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 4c53fab..fff5835 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -42,7 +42,7 @@ but you can pass the -config parameter to define the location of the config file | auth.refresh_token_ttl_days | OPENVOXVIEW_AUTH_REFRESH_TOKEN_TTL_DAYS | 30 | int | Refresh token lifetime in days | | auth.db_path | OPENVOXVIEW_AUTH_DB_PATH | data/openvoxview.db | string | Path to SQLite database file | -When `auth.enabled` is `true`, all API endpoints (except `/api/v1/auth/login`, `/api/v1/auth/refresh`, and `/api/v1/version`) require a valid JWT bearer token. If no `jwt_secret` is configured, a random one is generated at startup (tokens will not survive restarts). +When `auth.enabled` is `true`, all API endpoints (except `/api/v1/auth/login`, `/api/v1/auth/refresh`, `/api/v1/version`, and `/api/v1/meta`) require a valid JWT bearer token. If no `jwt_secret` is configured, a random one is generated at startup (tokens will not survive restarts). To create the first admin user, run: @@ -63,6 +63,59 @@ Users can also be managed via the API endpoints when authenticated: | PUT | /api/v1/auth/users/:id | Update user | | DELETE | /api/v1/auth/users/:id | Delete user | +### SAML Authentication (EntraID / ADFS) + +| Option | Environment Variable | Default | Type | Description | +|---------------------------------|----------------------------------------------|----------------------------------------------------------------------------|--------|-------------------------------------------------| +| auth.saml.enabled | OPENVOXVIEW_AUTH_SAML_ENABLED | false | bool | Enable SAML 2.0 SSO authentication | +| auth.saml.idp_metadata_url | OPENVOXVIEW_AUTH_SAML_IDP_METADATA_URL | | string | URL to IdP federation metadata XML | +| auth.saml.idp_metadata_file | OPENVOXVIEW_AUTH_SAML_IDP_METADATA_FILE | | string | Path to local IdP metadata XML file (fallback) | +| auth.saml.sp_entity_id | OPENVOXVIEW_AUTH_SAML_SP_ENTITY_ID | | string | SP Entity ID (e.g. https://openvoxview.example.com) | +| auth.saml.sp_acs_url | OPENVOXVIEW_AUTH_SAML_SP_ACS_URL | | string | Assertion Consumer Service URL | +| auth.saml.sp_cert_file | OPENVOXVIEW_AUTH_SAML_SP_CERT_FILE | | string | Path to SP X.509 certificate (PEM) | +| auth.saml.sp_key_file | OPENVOXVIEW_AUTH_SAML_SP_KEY_FILE | | string | Path to SP private key (PEM) | +| auth.saml.attr_email | | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress | string | SAML attribute URI for email | +| auth.saml.attr_given_name | | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname | string | SAML attribute URI for given name | +| auth.saml.attr_surname | | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname | string | SAML attribute URI for surname | +| auth.saml.attr_display_name | | http://schemas.microsoft.com/identity/claims/displayname | string | SAML attribute URI for display name | + +SAML requires `auth.enabled: true` as a prerequisite. Both local login and SAML SSO can be active simultaneously (recommended for break-glass admin access). + +To generate a self-signed SP certificate for SAML: + +``` +openvoxview --generate-saml-cert +``` + +This creates `saml-sp.crt` and `saml-sp.key` in the current directory. Point `sp_cert_file` and `sp_key_file` to these files. + +SAML API endpoints (public, no auth required): + +| Method | Endpoint | Description | +|--------|----------------------------------|------------------------------------| +| GET | /api/v1/auth/saml/metadata | SP metadata XML (for IdP setup) | +| GET | /api/v1/auth/saml/login | Initiates SAML SSO redirect to IdP | +| POST | /api/v1/auth/saml/acs | Assertion Consumer Service callback| + +After the IdP returns a valid assertion, the user is auto-provisioned in the local database (with `auth_source = 'saml'`) and redirected to the frontend with JWT tokens. + +#### EntraID Setup + +1. Azure Portal > Enterprise Applications > New Application > Create your own (non-gallery) +2. Single Sign-On > SAML +3. Basic SAML Configuration: + - Identifier (Entity ID): value of `sp_entity_id` + - Reply URL (ACS): value of `sp_acs_url` + - Sign on URL: `https:///api/v1/auth/saml/login` +4. Copy the **App Federation Metadata Url** and use it as `idp_metadata_url` +5. Assign users/groups + +#### ADFS Setup + +1. ADFS Management > Relying Party Trusts > Add +2. Import from URL: `https:///api/v1/auth/saml/metadata` +3. Add claim rules for email, given name, surname, and display name + ### predefined Queries | Option | Type | Description | |-------------|--------|---------------------------| @@ -117,6 +170,14 @@ auth: refresh_token_ttl_days: 30 db_path: "data/openvoxview.db" + saml: + enabled: true + idp_metadata_url: "https://login.microsoftonline.com//federationmetadata/2007-06/federationmetadata.xml" + sp_entity_id: "https://openvoxview.example.com" + sp_acs_url: "https://openvoxview.example.com/api/v1/auth/saml/acs" + sp_cert_file: "/etc/openvoxview/saml-sp.crt" + sp_key_file: "/etc/openvoxview/saml-sp.key" + puppetdb: host: localhost port: 8081 diff --git a/config/config.go b/config/config.go index 06cb8b4..4ce78cd 100644 --- a/config/config.go +++ b/config/config.go @@ -13,6 +13,7 @@ import ( var configPath = flag.String("config", "", "path to the config file") var printVersion = flag.Bool("version", false, "prints version") var createAdmin = flag.Bool("create-admin", false, "create an admin user interactively") +var generateSamlCert = flag.Bool("generate-saml-cert", false, "generate a self-signed SAML SP certificate and key") func init() { flag.Parse() @@ -23,12 +24,27 @@ type ConfigPqlQuery struct { Query string `mapstructure:"query"` } +type SamlConfig struct { + Enabled bool `mapstructure:"enabled"` + IdpMetadataURL string `mapstructure:"idp_metadata_url"` + IdpMetadataFile string `mapstructure:"idp_metadata_file"` + SpEntityID string `mapstructure:"sp_entity_id"` + SpAcsURL string `mapstructure:"sp_acs_url"` + SpCertFile string `mapstructure:"sp_cert_file"` + SpKeyFile string `mapstructure:"sp_key_file"` + AttrEmail string `mapstructure:"attr_email"` + AttrGivenName string `mapstructure:"attr_given_name"` + AttrSurname string `mapstructure:"attr_surname"` + AttrDisplayName string `mapstructure:"attr_display_name"` +} + type AuthConfig struct { - Enabled bool `mapstructure:"enabled"` - JwtSecret string `mapstructure:"jwt_secret"` - AccessTokenTTL int `mapstructure:"access_token_ttl_minutes"` - RefreshTokenTTL int `mapstructure:"refresh_token_ttl_days"` - DbPath string `mapstructure:"db_path"` + Enabled bool `mapstructure:"enabled"` + JwtSecret string `mapstructure:"jwt_secret"` + AccessTokenTTL int `mapstructure:"access_token_ttl_minutes"` + RefreshTokenTTL int `mapstructure:"refresh_token_ttl_days"` + DbPath string `mapstructure:"db_path"` + Saml SamlConfig `mapstructure:"saml"` } type Config struct { @@ -74,6 +90,10 @@ func CreateAdmin() bool { return *createAdmin } +func GenerateSamlCert() bool { + return *generateSamlCert +} + var ( cachedConfig *Config cachedErr error @@ -101,6 +121,11 @@ func GetConfig() (*Config, error) { viper.SetDefault("auth.access_token_ttl_minutes", 15) viper.SetDefault("auth.refresh_token_ttl_days", 30) viper.SetDefault("auth.db_path", "data/openvoxview.db") + viper.SetDefault("auth.saml.enabled", false) + viper.SetDefault("auth.saml.attr_email", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress") + viper.SetDefault("auth.saml.attr_given_name", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname") + viper.SetDefault("auth.saml.attr_surname", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname") + viper.SetDefault("auth.saml.attr_display_name", "http://schemas.microsoft.com/identity/claims/displayname") viper.SetDefault("puppetca.port", 8140) viper.SetDefault("puppetca.tls", true) @@ -127,6 +152,13 @@ func GetConfig() (*Config, error) { viper.BindEnv("auth.access_token_ttl_minutes", "OPENVOXVIEW_AUTH_ACCESS_TOKEN_TTL_MINUTES") viper.BindEnv("auth.refresh_token_ttl_days", "OPENVOXVIEW_AUTH_REFRESH_TOKEN_TTL_DAYS") viper.BindEnv("auth.db_path", "OPENVOXVIEW_AUTH_DB_PATH") + viper.BindEnv("auth.saml.enabled", "OPENVOXVIEW_AUTH_SAML_ENABLED") + viper.BindEnv("auth.saml.idp_metadata_url", "OPENVOXVIEW_AUTH_SAML_IDP_METADATA_URL") + viper.BindEnv("auth.saml.idp_metadata_file", "OPENVOXVIEW_AUTH_SAML_IDP_METADATA_FILE") + viper.BindEnv("auth.saml.sp_entity_id", "OPENVOXVIEW_AUTH_SAML_SP_ENTITY_ID") + viper.BindEnv("auth.saml.sp_acs_url", "OPENVOXVIEW_AUTH_SAML_SP_ACS_URL") + viper.BindEnv("auth.saml.sp_cert_file", "OPENVOXVIEW_AUTH_SAML_SP_CERT_FILE") + viper.BindEnv("auth.saml.sp_key_file", "OPENVOXVIEW_AUTH_SAML_SP_KEY_FILE") viper.BindEnv("puppetca.host", "PUPPETCA_HOST") viper.BindEnv("puppetca.port", "PUPPETCA_PORT") diff --git a/db/users.go b/db/users.go index af9e950..4f14f32 100644 --- a/db/users.go +++ b/db/users.go @@ -186,6 +186,39 @@ func (d *Database) DeleteUser(id int64) error { return nil } +func (d *Database) UpsertSamlUser(email, givenName, surname, displayName string) (*User, error) { + // Use email as the username for SAML users + username := email + + var existingID int64 + err := d.db.QueryRow(`SELECT id FROM users WHERE username = ? AND auth_source = 'saml'`, username).Scan(&existingID) + if err == nil { + // User exists — update profile attributes from IdP + _, err = d.db.Exec( + `UPDATE users SET email = ?, given_name = ?, surname = ?, display_name = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + email, givenName, surname, displayName, existingID, + ) + if err != nil { + return nil, fmt.Errorf("failed to update SAML user: %w", err) + } + return d.GetUserByID(existingID) + } + + // New SAML user — insert + result, err := d.db.Exec( + `INSERT INTO users (username, email, given_name, surname, display_name, password_hash, auth_source) + VALUES (?, ?, ?, ?, ?, NULL, 'saml')`, + username, email, givenName, surname, displayName, + ) + if err != nil { + return nil, fmt.Errorf("failed to create SAML user: %w", err) + } + + id, _ := result.LastInsertId() + return d.GetUserByID(id) +} + func (d *Database) UserCount() (int64, error) { var count int64 err := d.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count) diff --git a/docs/adr/ADR-002-saml-authentication.md b/docs/adr/ADR-002-saml-authentication.md index 96c54a4..b881cf0 100644 --- a/docs/adr/ADR-002-saml-authentication.md +++ b/docs/adr/ADR-002-saml-authentication.md @@ -1,6 +1,6 @@ # ADR-002: SAML Authentication (EntraID / ADFS) -**Status:** Proposed +**Status:** Implemented **Date:** 2026-04-03 **Deciders:** OpenVox View maintainers **Depends on:** ADR-001 (shares SQLite DB, JWT issuance, refresh token mechanism, auth middleware, and frontend auth store) diff --git a/docs/testing/auth-test-plan.md b/docs/testing/auth-test-plan.md index 474315d..2b24279 100644 --- a/docs/testing/auth-test-plan.md +++ b/docs/testing/auth-test-plan.md @@ -58,7 +58,7 @@ Manual test plan for local user authentication. ## User Management UI (ADR-003) ### Visibility -- [ ] **"Users" menu item visible** in sidebar when auth is enabled +- [x] **"Users" menu item visible** in sidebar when auth is enabled - [ ] **"Users" menu item hidden** when auth is disabled ### User Table @@ -66,21 +66,21 @@ Manual test plan for local user authentication. - [ ] **Refresh button** — reloads the user list ### Create User -- [ ] **Click "Add User"** — opens create dialog with empty fields -- [ ] **Submit with valid data** — user created, success notification, table refreshes -- [ ] **Submit with missing username** — shows "Username is required" error -- [ ] **Submit with short password** (< 8 chars) — shows "Password must be at least 8 characters" -- [ ] **Submit with mismatched passwords** — shows "Passwords do not match" -- [ ] **Submit with duplicate username** — shows "Username already exists" -- [ ] **Cancel button** — closes dialog without creating +- [x] **Click "Add User"** — opens create dialog with empty fields +- [x] **Submit with valid data** — user created, success notification, table refreshes +- [x] **Submit with missing username** — shows "Username is required" error +- [x] **Submit with short password** (< 8 chars) — shows "Password must be at least 8 characters" +- [x] **Submit with mismatched passwords** — shows "Passwords do not match" +- [x] **Submit with duplicate username** — shows "Username already exists" +- [x] **Cancel button** — closes dialog without creating ### Edit User -- [ ] **Click edit icon on a user row** — opens dialog pre-populated with user data -- [ ] **Username field is read-only** in edit mode +- [x] **Click edit icon on a user row** — opens dialog pre-populated with user data +- [x] **Username field is read-only** in edit mode - [ ] **Update display name / email** — saves, success notification, table refreshes - [ ] **Change password** — confirm password field appears, saves new password -- [ ] **Leave password blank** — keeps existing password unchanged -- [ ] **Cancel button** — closes dialog without saving +- [x] **Leave password blank** — keeps existing password unchanged +- [x] **Cancel button** — closes dialog without saving ### Delete User - [ ] **Click delete icon** — shows confirmation dialog with username diff --git a/go.mod b/go.mod index 9354037..3772106 100644 --- a/go.mod +++ b/go.mod @@ -9,15 +9,20 @@ require ( ) require ( + github.com/beevik/etree v1.5.0 // indirect + github.com/crewjam/saml v0.5.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/russellhaering/goxmldsig v1.4.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect diff --git a/go.sum b/go.sum index 7cb1677..4ce3b1e 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,15 @@ +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs= +github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/crewjam/saml v0.5.1 h1:g+mfp0CrLuLRZCK793PgJcZeg5dS/0CDwoeAX2zcwNI= +github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhyEolQ= 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= @@ -42,16 +48,25 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +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.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= 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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -63,6 +78,7 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -71,8 +87,12 @@ github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3Adq github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= 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/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys= +github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -89,6 +109,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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= @@ -128,9 +149,12 @@ golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 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= modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= diff --git a/handler/auth.go b/handler/auth.go index a440ac0..a2a1b0b 100644 --- a/handler/auth.go +++ b/handler/auth.go @@ -1,13 +1,16 @@ package handler import ( + "encoding/xml" "errors" + "fmt" "log" "net/http" "strconv" "sync" "time" + "github.com/crewjam/saml" "github.com/gin-gonic/gin" "github.com/sebastianrakel/openvoxview/config" "github.com/sebastianrakel/openvoxview/db" @@ -18,6 +21,7 @@ type AuthHandler struct { config *config.Config database *db.Database rateLimiter *rateLimiter + samlSP *middleware.SamlSP } func NewAuthHandler(config *config.Config, database *db.Database) *AuthHandler { @@ -28,6 +32,15 @@ func NewAuthHandler(config *config.Config, database *db.Database) *AuthHandler { } } +func NewAuthHandlerWithSAML(config *config.Config, database *db.Database, samlSP *middleware.SamlSP) *AuthHandler { + return &AuthHandler{ + config: config, + database: database, + rateLimiter: newRateLimiter(), + samlSP: samlSP, + } +} + type loginRequest struct { Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required"` @@ -271,6 +284,105 @@ func (h *AuthHandler) issueTokenPair(user *db.User) (*loginResponse, error) { }, nil } +// SamlMetadata returns the SP metadata XML for IdP registration. +func (h *AuthHandler) SamlMetadata(c *gin.Context) { + if h.samlSP == nil { + c.AbortWithStatusJSON(http.StatusNotFound, NewErrorResponse(errors.New("SAML not configured"))) + return + } + sp := h.samlSP.SP() + metadata := sp.Metadata() + data, err := xml.MarshalIndent(metadata, "", " ") + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + c.Data(http.StatusOK, "application/xml", data) +} + +// SamlLogin initiates SAML SSO by redirecting to the IdP. +func (h *AuthHandler) SamlLogin(c *gin.Context) { + if h.samlSP == nil { + c.AbortWithStatusJSON(http.StatusNotFound, NewErrorResponse(errors.New("SAML not configured"))) + return + } + sp := h.samlSP.SP() + + authnRequest, err := sp.MakeAuthenticationRequest( + sp.GetSSOBindingLocation(saml.HTTPRedirectBinding), + saml.HTTPRedirectBinding, + saml.HTTPPostBinding, + ) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(fmt.Errorf("failed to create SAML AuthnRequest: %w", err))) + return + } + + redirectURL, err := authnRequest.Redirect("", &sp) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(fmt.Errorf("failed to build SAML redirect URL: %w", err))) + return + } + + c.Redirect(http.StatusFound, redirectURL.String()) +} + +// SamlACS handles the SAML Assertion Consumer Service callback. +func (h *AuthHandler) SamlACS(c *gin.Context) { + if h.samlSP == nil { + c.AbortWithStatusJSON(http.StatusNotFound, NewErrorResponse(errors.New("SAML not configured"))) + return + } + + sp := h.samlSP.SP() + + err := c.Request.ParseForm() + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(fmt.Errorf("failed to parse form: %w", err))) + return + } + + assertion, err := sp.ParseResponse(c.Request, []string{""}) + if err != nil { + log.Printf("[SAML] ACS assertion validation failed: %v", err) + c.AbortWithStatusJSON(http.StatusUnauthorized, NewErrorResponse(fmt.Errorf("SAML assertion validation failed: %w", err))) + return + } + + // Extract attributes + email := middleware.GetAttribute(assertion, h.config.Auth.Saml.AttrEmail) + givenName := middleware.GetAttribute(assertion, h.config.Auth.Saml.AttrGivenName) + surname := middleware.GetAttribute(assertion, h.config.Auth.Saml.AttrSurname) + displayName := middleware.GetAttribute(assertion, h.config.Auth.Saml.AttrDisplayName) + + if email == "" { + log.Printf("[SAML] ACS: no email attribute in assertion") + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(errors.New("SAML assertion missing required email attribute"))) + return + } + + // Upsert user in database + user, err := h.database.UpsertSamlUser(email, givenName, surname, displayName) + if err != nil { + log.Printf("[SAML] ACS: failed to upsert user %s: %v", email, err) + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + // Issue token pair (same as local login) + tokens, err := h.issueTokenPair(user) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + log.Printf("[AUDIT] SAML user logged in: %s", user.Username) + + // Redirect to frontend with tokens in query params + redirectURL := fmt.Sprintf("/ui/?#/login?token=%s&refresh=%s", tokens.AccessToken, tokens.RefreshToken) + c.Redirect(http.StatusFound, redirectURL) +} + // Simple in-memory rate limiter: 5 attempts per IP per minute type rateLimiter struct { mu sync.Mutex diff --git a/main.go b/main.go index 9e2ea8d..b60633a 100644 --- a/main.go +++ b/main.go @@ -2,13 +2,20 @@ package main import ( "bufio" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" "encoding/hex" + "encoding/pem" "fmt" "io/fs" "log" + "math/big" "net/http" "os" + "path/filepath" "strings" "time" @@ -40,6 +47,12 @@ func main() { return } + // Handle --generate-saml-cert + if config.GenerateSamlCert() { + runGenerateSamlCert() + return + } + log.Printf("LISTEN: %s", cfg.Listen) log.Printf("PORT: %d", cfg.Port) log.Printf("PUPPETDB_ADDRESS: %s", cfg.GetPuppetDbAddress()) @@ -47,6 +60,7 @@ func main() { // Initialize auth database if auth is enabled var database *db.Database + var samlSP *middleware.SamlSP if cfg.Auth.Enabled { if cfg.Auth.JwtSecret == "" { cfg.Auth.JwtSecret = generateRandomSecret() @@ -76,6 +90,16 @@ func main() { } }() + // Initialize SAML SP if SAML is enabled + if cfg.Auth.Saml.Enabled { + sp, samlErr := middleware.NewSamlServiceProvider(&cfg.Auth.Saml) + if samlErr != nil { + log.Fatalf("Failed to initialize SAML SP: %v", samlErr) + } + samlSP = sp + log.Printf("AUTH: SAML enabled (entity: %s)", cfg.Auth.Saml.SpEntityID) + } + log.Printf("AUTH: enabled (db: %s)", cfg.Auth.DbPath) } else { log.Printf("AUTH: disabled") @@ -105,9 +129,21 @@ func main() { // Public auth endpoints (no JWT required) if cfg.Auth.Enabled { - authHandler := handler.NewAuthHandler(cfg, database) + var authHandler *handler.AuthHandler + if samlSP != nil { + authHandler = handler.NewAuthHandlerWithSAML(cfg, database, samlSP) + } else { + authHandler = handler.NewAuthHandler(cfg, database) + } r.POST("/api/v1/auth/login", authHandler.Login) r.POST("/api/v1/auth/refresh", authHandler.Refresh) + + // SAML public endpoints (browser redirects, no token available) + if cfg.Auth.Saml.Enabled { + r.GET("/api/v1/auth/saml/metadata", authHandler.SamlMetadata) + r.GET("/api/v1/auth/saml/login", authHandler.SamlLogin) + r.POST("/api/v1/auth/saml/acs", authHandler.SamlACS) + } } // Public endpoints (no JWT required) @@ -125,6 +161,7 @@ func main() { UnreportedHours uint64 StripPathPrefix string AuthEnabled bool + SamlEnabled bool } response := metaResponse{ @@ -133,6 +170,7 @@ func main() { UnreportedHours: cfg.UnreportedHours, StripPathPrefix: cfg.StripPathPrefix, AuthEnabled: cfg.Auth.Enabled, + SamlEnabled: cfg.Auth.Saml.Enabled, } c.JSON(http.StatusOK, handler.NewSuccessResponse(response)) @@ -165,15 +203,20 @@ func main() { // Auth management endpoints (require auth) if cfg.Auth.Enabled { - authHandler := handler.NewAuthHandler(cfg, database) + var protectedAuthHandler *handler.AuthHandler + if samlSP != nil { + protectedAuthHandler = handler.NewAuthHandlerWithSAML(cfg, database, samlSP) + } else { + protectedAuthHandler = handler.NewAuthHandler(cfg, database) + } auth := api.Group("auth") { - auth.POST("logout", authHandler.Logout) - auth.GET("me", authHandler.Me) - auth.GET("users", authHandler.ListUsers) - auth.POST("users", authHandler.CreateUser) - auth.PUT("users/:id", authHandler.UpdateUser) - auth.DELETE("users/:id", authHandler.DeleteUser) + auth.POST("logout", protectedAuthHandler.Logout) + auth.GET("me", protectedAuthHandler.Me) + auth.GET("users", protectedAuthHandler.ListUsers) + auth.POST("users", protectedAuthHandler.CreateUser) + auth.PUT("users/:id", protectedAuthHandler.UpdateUser) + auth.DELETE("users/:id", protectedAuthHandler.DeleteUser) } } } @@ -257,3 +300,57 @@ func generateRandomSecret() string { rand.Read(b) return hex.EncodeToString(b) } + +func runGenerateSamlCert() { + outputDir := "." + if len(os.Args) > 2 { + for i, arg := range os.Args { + if arg == "--output-dir" && i+1 < len(os.Args) { + outputDir = os.Args[i+1] + } + } + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + log.Fatalf("Failed to generate private key: %v", err) + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "OpenVox View SAML SP", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), // 10 years + KeyUsage: x509.KeyUsageDigitalSignature, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + log.Fatalf("Failed to create certificate: %v", err) + } + + certPath := filepath.Join(outputDir, "saml-sp.crt") + certFile, err := os.Create(certPath) + if err != nil { + log.Fatalf("Failed to create cert file: %v", err) + } + pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + certFile.Close() + + keyPath := filepath.Join(outputDir, "saml-sp.key") + keyFile, err := os.OpenFile(keyPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + log.Fatalf("Failed to create key file: %v", err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + log.Fatalf("Failed to marshal private key: %v", err) + } + pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + keyFile.Close() + + fmt.Printf("Generated: %s\n", certPath) + fmt.Printf("Generated: %s\n", keyPath) +} diff --git a/middleware/saml.go b/middleware/saml.go new file mode 100644 index 0000000..5122b37 --- /dev/null +++ b/middleware/saml.go @@ -0,0 +1,181 @@ +package middleware + +import ( + "crypto" + "crypto/tls" + "crypto/x509" + "encoding/xml" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "sync" + "time" + + "github.com/crewjam/saml" + "github.com/sebastianrakel/openvoxview/config" +) + +// SamlSP wraps the crewjam/saml ServiceProvider with thread-safe metadata refresh. +type SamlSP struct { + mu sync.RWMutex + sp saml.ServiceProvider +} + +// SP returns a copy of the current ServiceProvider (safe for concurrent use). +func (s *SamlSP) SP() saml.ServiceProvider { + s.mu.RLock() + defer s.mu.RUnlock() + return s.sp +} + +func (s *SamlSP) updateIDPMetadata(metadata *saml.EntityDescriptor) { + s.mu.Lock() + defer s.mu.Unlock() + s.sp.IDPMetadata = metadata +} + +// NewSamlServiceProvider creates a SAML SP from the given config. +// It loads the SP certificate, fetches/parses IdP metadata, and returns a ready-to-use SamlSP. +func NewSamlServiceProvider(cfg *config.SamlConfig) (*SamlSP, error) { + // Load SP certificate and key + keyPair, err := tls.LoadX509KeyPair(cfg.SpCertFile, cfg.SpKeyFile) + if err != nil { + return nil, fmt.Errorf("failed to load SAML SP certificate: %w", err) + } + + leaf, err := x509.ParseCertificate(keyPair.Certificate[0]) + if err != nil { + return nil, fmt.Errorf("failed to parse SAML SP certificate: %w", err) + } + + // Parse IdP metadata + idpMetadata, err := fetchIDPMetadata(cfg) + if err != nil { + return nil, fmt.Errorf("failed to load IdP metadata: %w", err) + } + + entityIDURL, err := url.Parse(cfg.SpEntityID) + if err != nil { + return nil, fmt.Errorf("failed to parse SP entity ID URL: %w", err) + } + + acsURL, err := url.Parse(cfg.SpAcsURL) + if err != nil { + return nil, fmt.Errorf("failed to parse SP ACS URL: %w", err) + } + + signer, ok := keyPair.PrivateKey.(crypto.Signer) + if !ok { + return nil, fmt.Errorf("SAML SP private key does not implement crypto.Signer") + } + + sp := saml.ServiceProvider{ + EntityID: entityIDURL.String(), + Key: signer, + Certificate: leaf, + AcsURL: *acsURL, + IDPMetadata: idpMetadata, + AllowIDPInitiated: true, + } + + ssp := &SamlSP{sp: sp} + + // Start background metadata refresh if using a URL + if cfg.IdpMetadataURL != "" { + go ssp.refreshMetadataLoop(cfg.IdpMetadataURL) + } + + return ssp, nil +} + +func fetchIDPMetadata(cfg *config.SamlConfig) (*saml.EntityDescriptor, error) { + if cfg.IdpMetadataURL != "" { + return fetchIDPMetadataFromURL(cfg.IdpMetadataURL) + } + if cfg.IdpMetadataFile != "" { + return loadIDPMetadataFromFile(cfg.IdpMetadataFile) + } + return nil, fmt.Errorf("either idp_metadata_url or idp_metadata_file must be configured") +} + +func fetchIDPMetadataFromURL(metadataURL string) (*saml.EntityDescriptor, error) { + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(metadataURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch IdP metadata from %s: %w", metadataURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("IdP metadata URL returned status %d", resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read IdP metadata response: %w", err) + } + + return parseIDPMetadata(data) +} + +func loadIDPMetadataFromFile(path string) (*saml.EntityDescriptor, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read IdP metadata file %s: %w", path, err) + } + return parseIDPMetadata(data) +} + +func parseIDPMetadata(data []byte) (*saml.EntityDescriptor, error) { + // Try parsing as EntityDescriptor first + entity := &saml.EntityDescriptor{} + if err := xml.Unmarshal(data, entity); err == nil && entity.IDPSSODescriptors != nil { + return entity, nil + } + + // Try parsing as EntitiesDescriptor (federation metadata) + entities := &saml.EntitiesDescriptor{} + if err := xml.Unmarshal(data, entities); err != nil { + return nil, fmt.Errorf("failed to parse IdP metadata XML: %w", err) + } + + for i := range entities.EntityDescriptors { + if entities.EntityDescriptors[i].IDPSSODescriptors != nil { + return &entities.EntityDescriptors[i], nil + } + } + + return nil, fmt.Errorf("no IdP entity found in metadata") +} + +func (s *SamlSP) refreshMetadataLoop(metadataURL string) { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for range ticker.C { + metadata, err := fetchIDPMetadataFromURL(metadataURL) + if err != nil { + log.Printf("WARNING: Failed to refresh IdP metadata: %v", err) + continue + } + s.updateIDPMetadata(metadata) + log.Printf("SAML: IdP metadata refreshed from %s", metadataURL) + } +} + +// GetAttribute extracts a named attribute value from a SAML assertion. +func GetAttribute(assertion *saml.Assertion, name string) string { + for _, stmt := range assertion.AttributeStatements { + for _, attr := range stmt.Attributes { + if attr.Name == name || attr.FriendlyName == name { + if len(attr.Values) > 0 { + return attr.Values[0].Value + } + } + } + } + return "" +} diff --git a/ui/src/client/models.ts b/ui/src/client/models.ts index d283dd5..9d5846d 100644 --- a/ui/src/client/models.ts +++ b/ui/src/client/models.ts @@ -12,6 +12,7 @@ export interface ApiMeta { UnreportedHours: number StripPathPrefix: string AuthEnabled: boolean + SamlEnabled: boolean } export interface ApiVersion { diff --git a/ui/src/i18n/langs/de-DE.json b/ui/src/i18n/langs/de-DE.json index d84ea1a..6546286 100644 --- a/ui/src/i18n/langs/de-DE.json +++ b/ui/src/i18n/langs/de-DE.json @@ -109,5 +109,8 @@ "BTN_CREATE": "Erstellen", "NOTIFICATION_USER_CREATED": "Benutzer erfolgreich erstellt", "NOTIFICATION_USER_UPDATED": "Benutzer erfolgreich aktualisiert", - "NOTIFICATION_USER_DELETED": "Benutzer erfolgreich gelöscht" + "NOTIFICATION_USER_DELETED": "Benutzer erfolgreich gelöscht", + + "BTN_LOGIN_SSO": "Anmelden mit SSO", + "LABEL_LOGIN_OR": "oder" } diff --git a/ui/src/i18n/langs/en-US.json b/ui/src/i18n/langs/en-US.json index c210f98..64d3962 100644 --- a/ui/src/i18n/langs/en-US.json +++ b/ui/src/i18n/langs/en-US.json @@ -109,5 +109,8 @@ "BTN_CREATE": "Create", "NOTIFICATION_USER_CREATED": "User created successfully", "NOTIFICATION_USER_UPDATED": "User updated successfully", - "NOTIFICATION_USER_DELETED": "User deleted successfully" + "NOTIFICATION_USER_DELETED": "User deleted successfully", + + "BTN_LOGIN_SSO": "Login with SSO", + "LABEL_LOGIN_OR": "or" } diff --git a/ui/src/pages/LoginPage.vue b/ui/src/pages/LoginPage.vue index 7119ecf..d42bdde 100644 --- a/ui/src/pages/LoginPage.vue +++ b/ui/src/pages/LoginPage.vue @@ -49,6 +49,17 @@ :loading="loading" /> + + @@ -68,9 +79,10 @@ const username = ref(''); const password = ref(''); const loading = ref(false); const errorMessage = ref(''); +const samlEnabled = ref(false); onMounted(async () => { - // Handle token from SAML redirect (ADR-002 future) + // Handle token from SAML redirect (ADR-002) if (route.query.token) { auth.setAuth({ access_token: route.query.token as string, @@ -81,10 +93,11 @@ onMounted(async () => { return; } - // Check if auth is even enabled + // Check if auth is even enabled and if SAML is available try { const meta = await Backend.getMeta(); auth.setAuthEnabled(meta.data.Data.AuthEnabled); + samlEnabled.value = meta.data.Data.SamlEnabled; if (!meta.data.Data.AuthEnabled) { void router.replace({ name: 'Dashboard' }); return; @@ -99,6 +112,10 @@ onMounted(async () => { } }); +function samlLogin() { + window.location.href = '/api/v1/auth/saml/login'; +} + async function onLogin() { if (!username.value || !password.value) { errorMessage.value = 'Username and password are required'; From 36f3b09569747f0122e4c5a10c699ca8de5f17cd Mon Sep 17 00:00:00 2001 From: vitorio92 Date: Sat, 4 Apr 2026 09:41:39 +0200 Subject: [PATCH 09/24] Fix SAML assertion validation by tracking AuthnRequest ID in cookie The ACS endpoint was passing an empty request ID to ParseResponse, causing InResponseTo validation to fail. Now SamlLogin stores the AuthnRequest ID in a short-lived httpOnly cookie, and SamlACS reads it back for proper validation. Also fix SSO button styling to match the login page design. Co-Authored-By: Claude Opus 4.6 --- handler/auth.go | 14 +++++++++++++- ui/src/pages/LoginPage.vue | 9 +++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/handler/auth.go b/handler/auth.go index a2a1b0b..a957f24 100644 --- a/handler/auth.go +++ b/handler/auth.go @@ -318,6 +318,9 @@ func (h *AuthHandler) SamlLogin(c *gin.Context) { return } + // Store the request ID in a cookie so ACS can validate InResponseTo + c.SetCookie("saml_request_id", authnRequest.ID, 300, "/", "", false, true) + redirectURL, err := authnRequest.Redirect("", &sp) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(fmt.Errorf("failed to build SAML redirect URL: %w", err))) @@ -342,7 +345,16 @@ func (h *AuthHandler) SamlACS(c *gin.Context) { return } - assertion, err := sp.ParseResponse(c.Request, []string{""}) + // Retrieve the request ID from the cookie set during SamlLogin + var possibleRequestIDs []string + if requestID, err := c.Cookie("saml_request_id"); err == nil && requestID != "" { + possibleRequestIDs = append(possibleRequestIDs, requestID) + } + + // Clear the cookie + c.SetCookie("saml_request_id", "", -1, "/", "", false, true) + + assertion, err := sp.ParseResponse(c.Request, possibleRequestIDs) if err != nil { log.Printf("[SAML] ACS assertion validation failed: %v", err) c.AbortWithStatusJSON(http.StatusUnauthorized, NewErrorResponse(fmt.Errorf("SAML assertion validation failed: %w", err))) diff --git a/ui/src/pages/LoginPage.vue b/ui/src/pages/LoginPage.vue index d42bdde..01313cd 100644 --- a/ui/src/pages/LoginPage.vue +++ b/ui/src/pages/LoginPage.vue @@ -51,11 +51,16 @@ ``` +### User Management UI — SAML users are read-only + +The IdP is the source of truth for SAML user profiles. When editing a SAML user (`auth_source = 'saml'`) in the User Management page: + +- **Email** and **Display Name** fields are disabled (managed by IdP) +- **Password** fields are hidden (SAML users have no local password) +- **Save** button is hidden (no editable fields) +- An info banner explains that the profile is managed by the identity provider and updated on each login + No other frontend files need changes — the auth store, axios interceptors, router guard, and token refresh logic from ADR-001 all work unchanged for SAML-authenticated users. --- From feaf17015d0f39153d6abdfe11d1696663f9d071 Mon Sep 17 00:00:00 2001 From: vitorio92 Date: Sat, 4 Apr 2026 11:14:41 +0200 Subject: [PATCH 14/24] Log config file parse errors instead of silently ignoring viper.ReadInConfig() was discarding errors, causing the app to fall back to defaults with no warning if the config file had YAML issues. Also update auth test plan with SAML test results. Co-Authored-By: Claude Opus 4.6 --- config/config.go | 6 +++- docs/testing/auth-test-plan.md | 62 ++++++++++++++++++++++++++++------ 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/config/config.go b/config/config.go index 4ce78cd..66e1dd5 100644 --- a/config/config.go +++ b/config/config.go @@ -170,7 +170,11 @@ func GetConfig() (*Config, error) { viper.BindEnv("puppetca.readonly", "PUPPETCA_READONLY") viper.BindEnv("puppetca.deactivate_nodes", "PUPPETCA_DEACTIVATE_NODES") - viper.ReadInConfig() + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + log.Printf("WARNING: Failed to read config file: %v", err) + } + } var cfg Config cachedErr = viper.Unmarshal(&cfg) diff --git a/docs/testing/auth-test-plan.md b/docs/testing/auth-test-plan.md index 2b24279..97941ec 100644 --- a/docs/testing/auth-test-plan.md +++ b/docs/testing/auth-test-plan.md @@ -59,11 +59,11 @@ Manual test plan for local user authentication. ### Visibility - [x] **"Users" menu item visible** in sidebar when auth is enabled -- [ ] **"Users" menu item hidden** when auth is disabled +- [x] **"Users" menu item hidden** when auth is disabled ### User Table -- [ ] **Users page loads** — shows table with all users, columns: Username, Display Name, Email, Auth Source, Created, Actions -- [ ] **Refresh button** — reloads the user list +- [x] **Users page loads** — shows table with all users, columns: Username, Display Name, Email, Auth Source, Created, Actions +- [x] **Refresh button** — reloads the user list ### Create User - [x] **Click "Add User"** — opens create dialog with empty fields @@ -77,16 +77,58 @@ Manual test plan for local user authentication. ### Edit User - [x] **Click edit icon on a user row** — opens dialog pre-populated with user data - [x] **Username field is read-only** in edit mode -- [ ] **Update display name / email** — saves, success notification, table refreshes -- [ ] **Change password** — confirm password field appears, saves new password +- [x] **Update display name / email** — saves, success notification, table refreshes +- [x] **Change password** — confirm password field appears, saves new password - [x] **Leave password blank** — keeps existing password unchanged - [x] **Cancel button** — closes dialog without saving ### Delete User -- [ ] **Click delete icon** — shows confirmation dialog with username -- [ ] **Confirm delete** — user removed, success notification, table refreshes -- [ ] **Cancel delete** — dialog closes, user not deleted -- [ ] **Delete button disabled on own row** — tooltip shows "Cannot delete your own account" +- [x] **Click delete icon** — shows confirmation dialog with username +- [x] **Confirm delete** — user removed, success notification, table refreshes +- [x] **Cancel delete** — dialog closes, user not deleted +- [x] **Delete button disabled on own row** — tooltip shows "Cannot delete your own account" ### i18n -- [ ] **German locale** — all labels, buttons, and messages display in German +- [x] **German locale** — all labels, buttons, and messages display in German + +## SAML Authentication (ADR-002) + +### Configuration +- [x] **App Federation Metadata URL** — must use the app-specific URL with `?appid=` parameter +- [x] **Missing SP cert files** — server should fail to start with clear error +- [x] **Missing IdP metadata URL and file** — server should fail to start with clear error +- [x] **Invalid IdP metadata URL** — server should fail to start with clear error + +### SSO Login Flow +- [x] **SSO button visible** on login page when `auth.saml.enabled: true` +- [x] **SSO button hidden** when `auth.saml.enabled: false` +- [x] **Click "Login with SSO"** — redirects to EntraID login +- [x] **Authenticate at EntraID** — redirected back to OpenVox View, logged in, lands on Dashboard +- [x] **SAML user auto-provisioned** — user appears in `GET /api/v1/auth/users` with `auth_source: saml` +- [x] **SAML user profile attributes** — email, display name, given name, surname populated from IdP claims +- [x] **Repeat SAML login** — profile attributes updated (upsert), no duplicate user created +- [ ] **SAML user cannot local login** — attempting local login with SAML user's email should fail + +### Session Behavior +- [ ] **Token refresh works for SAML users** — after access token expires, silent refresh works +- [x] **Logout works for SAML users** — click Logout, redirected to login page +- [x] **After SAML logout, press back button** — should not access protected content + +### Coexistence with Local Auth +- [x] **Local login still works** when SAML is enabled — both buttons on login page +- [x] **Local user and SAML user can coexist** — different auth_source values +- [x] **Break-glass admin** — local admin account works even if IdP is unreachable + +### SP Metadata +- [ ] **`GET /api/v1/auth/saml/metadata`** — returns valid XML with SP entity ID, ACS URL, and certificate + +### CLI +- [x] **`--generate-saml-cert`** — creates `saml-sp.crt` and `saml-sp.key` in current directory + +### User Management UI +- [x] **SAML users visible** in Users table with auth source "saml" +- [x] **SAML user edit dialog** — shows info banner "managed by identity provider" +- [x] **SAML user email/display name disabled** — fields are greyed out, not editable +- [x] **SAML user password fields hidden** — no password fields shown for SAML users +- [x] **SAML user save button hidden** — only Close button available +- [x] **SAML users deletable** — can delete a SAML-provisioned user From ad1ff8e4b20554194bb32114ea55de0d38fc0990 Mon Sep 17 00:00:00 2001 From: vitorio92 Date: Sat, 4 Apr 2026 11:44:53 +0200 Subject: [PATCH 15/24] Remove CLAUDE.md from repository Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 81 ------------------------------------------------------- 1 file changed, 81 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 1e13f29..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,81 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## What This Project Is - -OpenVox View is a web-based dashboard for PuppetDB/OpenVoxDB infrastructure monitoring, inspired by Puppetboard. It consists of a Go backend API server and a Vue 3 + Quasar frontend. - -## Build & Development Commands - -### Full Build -```bash -make all # Build everything (frontend first, then backend binary) -make ui # Build frontend only (yarn install + yarn build) -make backend # Build Go binary (requires frontend to be built first) -``` - -### Development (Hot Reload) -```bash -make develop-backend # Go backend with hot reload via `air` -make develop-frontend # Frontend dev server at http://localhost:9000 (proxies to backend at :5000) -make develop-backend-crafty # Backend with Puppet TLS settings (PUPPETDB_TLS_IGNORE=true, port 8081) -``` - -### Frontend (from `ui/` directory) -```bash -yarn lint # ESLint -yarn format # Prettier -yarn test # (stubbed, no tests currently) -``` - -### Go Backend -```bash -go build ./... # Build -go vet ./... # Vet -``` - -## Architecture - -### Data Flow -Frontend → Go API (`/api/v1/*`) → PuppetDB or Puppet CA - -The backend embeds the built frontend assets via `uiserve.go` so the whole app ships as a single binary. - -### Backend (Go + Gin) - -- **[main.go](main.go)** — Gin router setup, CORS config, route registration for all `/api/v1/*` endpoints -- **[uiserve.go](uiserve.go)** — Serves embedded Vue build assets for non-API routes -- **[config/config.go](config/config.go)** — Viper-based config loading from `config.yaml` or environment variables (see CONFIGURATION.md) -- **[handler/](handler/)** — HTTP handlers grouped by domain: - - `view.go` — Node overview, metrics, predefined views, fact rendering - - `pdb.go` — PuppetDB query execution, query history (in-memory), predefined queries - - `ca.go` — Puppet CA certificate management (sign/revoke/clean) - - `core.go` — Shared response helpers -- **[model/](model/)** — Domain structs (nodes, facts, events, certificates, metrics) -- **[puppetdb/client.go](puppetdb/client.go)** — PuppetDB HTTP client (supports TLS + client certs) -- **[puppetca/client.go](puppetca/client.go)** — Puppet CA HTTP client - -### Frontend (Vue 3 + Quasar + TypeScript, in `ui/src/`) - -- **[ui/src/pages/](ui/src/pages/)** — Route-level page components -- **[ui/src/components/](ui/src/components/)** — Reusable Vue components -- **[ui/src/stores/](ui/src/stores/)** — Pinia state stores -- **[ui/src/client/](ui/src/client/)** — Axios-based API client for the Go backend -- **[ui/src/puppet/](ui/src/puppet/)** — Puppet-domain utilities -- **[ui/src/router/](ui/src/router/)** — Vue Router config -- **[ui/src/i18n/](ui/src/i18n/)** — i18n translations - -### API Structure -All backend endpoints are under `/api/v1/`: -- `GET /meta` — App metadata (CA enabled flag, unreported hours threshold, etc.) -- `GET /version` — Version info -- `/view/*` — Dashboard views (nodes, metrics, facts) -- `/pdb/*` — PuppetDB query execution and history -- `/ca/*` — Certificate management - -### Key Config Points -- Default backend port: **5000** -- Config file: `config.yaml` (see CONFIGURATION.md for all options) -- TLS is fully supported for both PuppetDB and Puppet CA connections -- Query history is stored in-memory in `PdbHandler` From 9af77ca13af246b52b43db3595248ac90f5cce7b Mon Sep 17 00:00:00 2001 From: vitorio92 Date: Sat, 4 Apr 2026 11:52:34 +0200 Subject: [PATCH 16/24] Update ADR documents, CONFIGURATION.md, and add screenshots - ADR-001/002/003: Status updated to Implemented, checklists checked off - ADR-001: Fix password_hash to nullable, move /api/v1/meta to public routes - ADR-002: Add cookie-based request ID tracking to ACS pseudocode, note ?appid= requirement for EntraID metadata URL - CONFIGURATION.md: Add ?appid= note to EntraID setup and YAML example - Add UI screenshots for login, user management, create/edit dialogs Co-Authored-By: Claude Opus 4.6 --- CONFIGURATION.md | 4 +- docs/adr/ADR-001-local-user-authentication.md | 40 ++++++------- docs/adr/ADR-002-saml-authentication.md | 53 ++++++++++-------- docs/adr/ADR-003-user-management-ui.md | 13 +++-- docs/testing/auth-test-plan.md | 4 +- screenshots/create-user.png | Bin 0 -> 110544 bytes screenshots/edit-user-warning.png | Bin 0 -> 116999 bytes screenshots/login.png | Bin 0 -> 42760 bytes screenshots/manage-users.png | Bin 0 -> 70982 bytes 9 files changed, 61 insertions(+), 53 deletions(-) create mode 100644 screenshots/create-user.png create mode 100644 screenshots/edit-user-warning.png create mode 100644 screenshots/login.png create mode 100644 screenshots/manage-users.png diff --git a/CONFIGURATION.md b/CONFIGURATION.md index fff5835..23880ce 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -107,7 +107,7 @@ After the IdP returns a valid assertion, the user is auto-provisioned in the loc - Identifier (Entity ID): value of `sp_entity_id` - Reply URL (ACS): value of `sp_acs_url` - Sign on URL: `https:///api/v1/auth/saml/login` -4. Copy the **App Federation Metadata Url** and use it as `idp_metadata_url` +4. Copy the **App Federation Metadata Url** (must include `?appid=`) from Section 3 (SAML Certificates) and use it as `idp_metadata_url`. Do NOT use the generic tenant metadata URL — it contains the wrong signing certificate. 5. Assign users/groups #### ADFS Setup @@ -172,7 +172,7 @@ auth: saml: enabled: true - idp_metadata_url: "https://login.microsoftonline.com//federationmetadata/2007-06/federationmetadata.xml" + idp_metadata_url: "https://login.microsoftonline.com//federationmetadata/2007-06/federationmetadata.xml?appid=" sp_entity_id: "https://openvoxview.example.com" sp_acs_url: "https://openvoxview.example.com/api/v1/auth/saml/acs" sp_cert_file: "/etc/openvoxview/saml-sp.crt" diff --git a/docs/adr/ADR-001-local-user-authentication.md b/docs/adr/ADR-001-local-user-authentication.md index 8bf9ba0..4dbeec6 100644 --- a/docs/adr/ADR-001-local-user-authentication.md +++ b/docs/adr/ADR-001-local-user-authentication.md @@ -1,6 +1,6 @@ # ADR-001: Local User Authentication -**Status:** Proposed +**Status:** Implemented **Date:** 2026-04-03 **Deciders:** OpenVox View maintainers @@ -59,7 +59,7 @@ CREATE TABLE users ( username TEXT UNIQUE NOT NULL, email TEXT, display_name TEXT, - password_hash TEXT NOT NULL, -- bcrypt hash + password_hash TEXT, -- bcrypt hash (NULL for SAML users) auth_source TEXT NOT NULL DEFAULT 'local', -- 'local' | 'saml' (ADR-002) created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP @@ -200,12 +200,12 @@ func JWTAuthMiddleware(cfg *config.Config) gin.HandlerFunc { r.POST("/api/v1/auth/login", authHandler.Login) r.POST("/api/v1/auth/refresh", authHandler.Refresh) r.GET("/api/v1/version", ...) +r.GET("/api/v1/meta", ...) // public so frontend can detect auth state // Protected api := r.Group("/api/v1/") api.Use(middleware.JWTAuthMiddleware(cfg)) { - api.GET("meta", ...) api.GET("view/*", ...) api.POST("pdb/*", ...) api.POST("ca/*", ...) @@ -385,22 +385,22 @@ auth: ## Implementation Checklist **Backend** -- [ ] Add `modernc.org/sqlite` to `go.mod` -- [ ] Add `golang-jwt/jwt/v5` to `go.mod` -- [ ] Create `db/` package: `db.go`, `users.go`, `tokens.go` -- [ ] Add `AuthConfig` to `config/config.go` with Viper bindings and env vars -- [ ] Create `middleware/auth.go` (JWT validation, auth-disabled passthrough) -- [ ] Create `handler/auth.go` (login, refresh, logout, me, user CRUD) -- [ ] Modify `main.go`: open DB, init handlers, register routes (public vs. protected) -- [ ] Add `--create-admin` CLI flag -- [ ] Update `CONFIGURATION.md` +- [x] Add `modernc.org/sqlite` to `go.mod` +- [x] Add `golang-jwt/jwt/v5` to `go.mod` +- [x] Create `db/` package: `db.go`, `users.go`, `tokens.go` +- [x] Add `AuthConfig` to `config/config.go` with Viper bindings and env vars +- [x] Create `middleware/auth.go` (JWT validation, auth-disabled passthrough) +- [x] Create `handler/auth.go` (login, refresh, logout, me, user CRUD) +- [x] Modify `main.go`: open DB, init handlers, register routes (public vs. protected) +- [x] Add `--create-admin` CLI flag +- [x] Update `CONFIGURATION.md` **Frontend** -- [ ] Create `ui/src/stores/auth.ts` -- [ ] Create `ui/src/pages/LoginPage.vue` -- [ ] Create `ui/src/layouts/AuthLayout.vue` -- [ ] Update `ui/src/router/routes.ts` (login route + `beforeEach` guard) -- [ ] Update `ui/src/boot/axios.ts` (request injector + 401 refresh interceptor) -- [ ] Update `ui/src/client/backend.ts` (auth methods) -- [ ] Update `ui/src/client/models.ts` (LoginResponse, UserProfile, etc.) -- [ ] Update `ui/src/layouts/MainLayout.vue` (user menu + logout) +- [x] Create `ui/src/stores/auth.ts` +- [x] Create `ui/src/pages/LoginPage.vue` +- [x] Create `ui/src/layouts/AuthLayout.vue` +- [x] Update `ui/src/router/routes.ts` (login route + `beforeEach` guard) +- [x] Update `ui/src/boot/axios.ts` (request injector + 401 refresh interceptor) +- [x] Update `ui/src/client/backend.ts` (auth methods) +- [x] Update `ui/src/client/models.ts` (LoginResponse, UserProfile, etc.) +- [x] Update `ui/src/layouts/MainLayout.vue` (user menu + logout) diff --git a/docs/adr/ADR-002-saml-authentication.md b/docs/adr/ADR-002-saml-authentication.md index 6980906..84c4b93 100644 --- a/docs/adr/ADR-002-saml-authentication.md +++ b/docs/adr/ADR-002-saml-authentication.md @@ -165,23 +165,29 @@ func (h *AuthHandler) SamlACS(c *gin.Context) ```go func (h *AuthHandler) SamlACS(c *gin.Context) { - // 1. Parse and validate SAML response (crewjam/saml handles sig, audience, timing) - assertion, err := h.sp.ParseResponse(c.Request, []string{relayState}) + // 1. Retrieve AuthnRequest ID from cookie (set during SamlLogin) + var possibleRequestIDs []string + if requestID, err := c.Cookie("saml_request_id"); err == nil { + possibleRequestIDs = append(possibleRequestIDs, requestID) + } - // 2. Extract attributes + // 2. Parse and validate SAML response (crewjam/saml handles sig, audience, timing) + assertion, err := h.sp.ParseResponse(c.Request, possibleRequestIDs) + + // 3. Extract attributes email := getAttr(assertion, h.cfg.Auth.Saml.AttrEmail) givenName := getAttr(assertion, h.cfg.Auth.Saml.AttrGivenName) surname := getAttr(assertion, h.cfg.Auth.Saml.AttrSurname) displayName := getAttr(assertion, h.cfg.Auth.Saml.AttrDisplayName) - // 3. Upsert user in SQLite + // 4. Upsert user in SQLite user, err := h.db.UpsertSamlUser(email, givenName, surname, displayName) - // 4. Issue access + refresh tokens (same function as local login) + // 5. Issue access + refresh tokens (same function as local login) tokens, err := h.issueTokenPair(user) - // 5. Redirect frontend with tokens - redirectURL := fmt.Sprintf("/login?token=%s&refresh=%s", tokens.AccessToken, tokens.RefreshToken) + // 6. Redirect frontend with tokens + redirectURL := fmt.Sprintf("/ui/?#/login?token=%s&refresh=%s", tokens.AccessToken, tokens.RefreshToken) c.Redirect(http.StatusFound, redirectURL) } ``` @@ -319,7 +325,7 @@ The `.crt` content is embedded in the SP metadata XML that the IdP reads. Copy-p - `givenname` → `user.givenname` - `surname` → `user.surname` - `displayname` → `user.displayname` -5. **SAML Certificates** → copy the **App Federation Metadata Url** → use as `idp_metadata_url` +5. **SAML Certificates** → copy the **App Federation Metadata Url** (must include `?appid=`) → use as `idp_metadata_url`. Do NOT use the generic tenant metadata URL — it contains the wrong signing certificate. 6. **Users and Groups** → assign the users/groups who should have access ### ADFS @@ -348,11 +354,11 @@ auth: db_path: "data/openvoxview.db" # Local users still work alongside SAML — useful for a break-glass admin - users: [] # managed via API saml: enabled: true - idp_metadata_url: "https://login.microsoftonline.com//federationmetadata/2007-06/federationmetadata.xml" + # IMPORTANT: For EntraID, use the App Federation Metadata URL (with ?appid=) + idp_metadata_url: "https://login.microsoftonline.com//federationmetadata/2007-06/federationmetadata.xml?appid=" sp_entity_id: "https://openvoxview.example.com" sp_acs_url: "https://openvoxview.example.com/api/v1/auth/saml/acs" sp_cert_file: "/etc/openvoxview/saml-sp.crt" @@ -404,18 +410,19 @@ auth: ## Implementation Checklist **Backend** -- [ ] Add `github.com/crewjam/saml` to `go.mod` -- [ ] Add `SamlConfig` struct to `config/config.go` with Viper bindings -- [ ] Add `given_name` / `surname` columns to `users` table migration in `db/users.go` -- [ ] Add `UpsertSamlUser()` to `db/users.go` -- [ ] Create `middleware/saml.go` — `NewSamlServiceProvider()`, hourly metadata refresh goroutine -- [ ] Add `SamlMetadata`, `SamlLogin`, `SamlACS` to `handler/auth.go` -- [ ] Add `SamlEnabled` to `MetaResponse` in `handler/core.go` (or `handler/view.go`) -- [ ] Register SAML routes in `main.go` (public, conditional on `cfg.Auth.Saml.Enabled`) -- [ ] Add `--generate-saml-cert` CLI flag -- [ ] Update `CONFIGURATION.md` with SAML config options and IdP setup guides +- [x] Add `github.com/crewjam/saml` to `go.mod` +- [x] Add `SamlConfig` struct to `config/config.go` with Viper bindings +- [x] Add `given_name` / `surname` columns to `users` table migration in `db/db.go` +- [x] Add `UpsertSamlUser()` to `db/users.go` +- [x] Create `middleware/saml.go` — `NewSamlServiceProvider()`, hourly metadata refresh goroutine +- [x] Add `SamlMetadata`, `SamlLogin`, `SamlACS` to `handler/auth.go` +- [x] Add `SamlEnabled` to meta response in `main.go` +- [x] Register SAML routes in `main.go` (public, conditional on `cfg.Auth.Saml.Enabled`) +- [x] Add `--generate-saml-cert` CLI flag +- [x] Update `CONFIGURATION.md` with SAML config options and IdP setup guides **Frontend** -- [ ] Add `SamlEnabled` to `ApiMeta` in `ui/src/client/models.ts` -- [ ] Add SAML token extraction + SSO button to `ui/src/pages/LoginPage.vue` -- [ ] No other frontend changes needed (auth store, interceptors, and router guard from ADR-001 are protocol-agnostic) +- [x] Add `SamlEnabled` to `ApiMeta` in `ui/src/client/models.ts` +- [x] Add SAML token extraction + SSO button to `ui/src/pages/LoginPage.vue` +- [x] Make SAML user profiles read-only in `UserManagementPage.vue` +- [x] Add i18n keys for SSO button and SAML user banner (en-US, de-DE) diff --git a/docs/adr/ADR-003-user-management-ui.md b/docs/adr/ADR-003-user-management-ui.md index f6d93b0..9a0d482 100644 --- a/docs/adr/ADR-003-user-management-ui.md +++ b/docs/adr/ADR-003-user-management-ui.md @@ -1,6 +1,6 @@ # ADR-003: User Management UI -**Status:** Proposed +**Status:** Implemented **Date:** 2026-04-03 **Deciders:** OpenVox View maintainers **Depends on:** ADR-001 (backend user CRUD API, auth store, auth-enabled detection) @@ -137,8 +137,9 @@ Placed after the CA menu item in the sidebar. ## Implementation Checklist -- [ ] Create `ui/src/pages/admin/UserManagementPage.vue` -- [ ] Add `UserManagement` route to `ui/src/router/routes.ts` -- [ ] Add "Users" menu item to `ui/src/layouts/MainLayout.vue` sidebar (conditional on `auth.authEnabled`) -- [ ] Build and verify (`yarn build`, `yarn lint`) -- [ ] Add test cases to `docs/testing/auth-test-plan.md` +- [x] Create `ui/src/pages/admin/UserManagementPage.vue` +- [x] Add `UserManagement` route to `ui/src/router/routes.ts` +- [x] Add "Users" menu item to `ui/src/layouts/MainLayout.vue` sidebar (conditional on `auth.authEnabled`) +- [x] Build and verify (`yarn build`, `yarn lint`) +- [x] Add test cases to `docs/testing/auth-test-plan.md` +- [x] Add i18n keys for all labels, buttons, and messages (en-US, de-DE) diff --git a/docs/testing/auth-test-plan.md b/docs/testing/auth-test-plan.md index 97941ec..758c761 100644 --- a/docs/testing/auth-test-plan.md +++ b/docs/testing/auth-test-plan.md @@ -110,7 +110,7 @@ Manual test plan for local user authentication. - [ ] **SAML user cannot local login** — attempting local login with SAML user's email should fail ### Session Behavior -- [ ] **Token refresh works for SAML users** — after access token expires, silent refresh works +- [x] **Token refresh works for SAML users** — after access token expires, silent refresh works - [x] **Logout works for SAML users** — click Logout, redirected to login page - [x] **After SAML logout, press back button** — should not access protected content @@ -120,7 +120,7 @@ Manual test plan for local user authentication. - [x] **Break-glass admin** — local admin account works even if IdP is unreachable ### SP Metadata -- [ ] **`GET /api/v1/auth/saml/metadata`** — returns valid XML with SP entity ID, ACS URL, and certificate +- [x] **`GET /api/v1/auth/saml/metadata`** — returns valid XML with SP entity ID, ACS URL, and certificate ### CLI - [x] **`--generate-saml-cert`** — creates `saml-sp.crt` and `saml-sp.key` in current directory diff --git a/screenshots/create-user.png b/screenshots/create-user.png new file mode 100644 index 0000000000000000000000000000000000000000..4f2a88d6c98711054dc6fd163b8a2a521f973994 GIT binary patch literal 110544 zcma&O2Ut@}7cPtym3B}NP|8t22uKkENLP^(AcWql^p5n7=n+Ja5}Nd$(0ho45)}~v zX$c(yg0v8dbO;1)I7d(U?tT8}p9kGLlWA*a&Dyiqdf!B8YADlOxp9Spf`SI5@>rXK z;xdqe;sW5(MRJag;>{TH<&2lM@*|4MURFH$!+HCM;D;0xHE~o&Ru{;hFMFsMc~MYY z=RW;+2BgjPgM#9u2=w@&uD|&TDInO?2X}HJ-&fwLo?_>EO=aZ4xuhpcXY20@6g_+O z{Pl=pBZFe)O~t&>c_^5n;@}cz=s}%s_pS@zVLhp`oJQUJV2iSq6u9 zNHH+qgE`(wiTW%Y^GU>*`YiZYl|Qeask$)#$zYan(Sd8|g@(3nZf=%`{O3Din2VX2 z*)krlCCpsEd*N*hW3L)`pN+4EP)W4-cn z5Q{BXTXSQHuwsJ_SDyv5u&{J>b|#Dvl5uVZTvrnu(}%78U60~U{M-+y6|)FfClqZ# zNyo11=jYdDSk`t0D4C@(zqr`_edCKTHTfQ#4g_7eaA7mBrR(e0q#nDv%8L~5wcw47 zM(F*Z_v6(6w95S8y|>Ctu8dNu=TIp?5Phey&2yZ_$Je)%T=;`!KPuh(e)yfLk+P2; zA2s6K0%X{G(#6yc4i2JplO5Av2-01kc<;Zsb>Q8nb`JjHUsDpfp#MPBg~RbR4;Lq^ zrnR;8xD!}`uK?(X;bLTD1aNXH@Z<#7F7)M8J%9dOjywOKA(8KXH+tYU^oRX&PebQ& z%sX9OU9PGTXEyQu2Z>}AaC57yt*i4P;^AfQ-o164|4&WoY^&lnanL$ttbtm8MP?>z zqp4t{Y2~N*hul!|a$KRJn%@cZSuTO|NlQ;io&QhWi$w}XlO4Nd{v{&?#e2& zJZv9JiD8X|KwWQuB{el0{fz0JUS788FuUGN(A&#@7bM@je}!)d7|J(A@EZ8q12eq8 zJV=~aP3ia!`l?0w?{XBJMf^6!ikn4dWoxht};_Xe~-Wt$ZYo*3LF~v-JWD}&cMO*J}Z<9j{U!c5w-o2bcy#din`JR!p{`mJv1T4l4SO>+u`rzBBeRp?v;-7Z8=0BF-((vM?du*!XH6b^iS;sy-m`}|}}AO(fKx}?PJS%eUv`uqH+)k7hE z6+FikcRaPT&WXrYVT_D3p?JTGE-Yt%?5EP<|6YWSvuaDlkc%Y{1^K&mZaZmzltIpU z463S2recjT0TbCMipzBZs!tZnBvgn6O77u zL8o*^neIDAO-RgDJOkC#G{1GAEmhM0N5a)G)9X{!N+Z39U1L4MOfp?*>Gl;I2{$ig z%#pQ>;NSU1;6}|>4jG%ftX}Y_&wYd)EqH|eF`qiWgh@-|>qGFj4IEa)X!7Pa470mV z4Z-aFD0PMRIapfvpNg3ih#OQwc`bpmw7}jH;8_y*$8g2UPF5$tcyu~ z*vB?@>5XRG@h)6R)|n0X>_W8Kc4KG5(wjB6im-8b4^&sHkRy8q$~k|J=mvzVC5paDB0LrWzkCEsmwrAz@@+(I{Qw~!fO~pdD-YjB zC$H-#!Q>Ptxh_|p%x|!3zZ1-=Dlfysc_i6 zzW7!hs~e$!+D~GwIJQY^eOIg!ZD;H3)M{dm1V@mq%AC&xP;9zFv95NfT~X1P)G1;X ztQsrA+G15K$C#lV_WpdLMM{~FkkCMj+E%?wUfrn|R;#D_kh^vQ>SBmBzuT{t`5I;9pZkVVSQ+o-GIf>48#4ngwU3WC20u zd*8m_AWu`bMtb3dpqvI3Jm{(phTiG<++b~cqR-azyLvWs$CBJ25f{Gf%p%5a9zr)` zRP@>JpMP7YiIw8HJ2I4mmO>pJf{jV?Nwb4GIS9m|^E9Rd^34hID_TKEb?Ol&OgYK$%|ZdTvh?Z84q->3 zbVbHmiZB?@g`fuX^vxBPlShI18$X_#cE@@FI716sl>6?e%7Uc&^AB@I;U*{55Wkl3 zyoExt{rNM!eMh7d?F8=NyMja9LTBQitLu|x&B?GRh8QfU*||*ok$y+eJ*D+%j3l0Z-Ab~6oxHQEkr^-R z2(8m{9VS0*zAb4dz_&Jo+SCz4Z;1E_{DLg4<5r3iX}0Cr>@`|xPZ6Ji&bGjepd^Q( z;go&KcOMM+jm5{t4sU(4d=N%ZU**_q@OJj&-QCK4VLDd&WmQ0-37wbKfAyyz)J8I2 z83y$18Jq^nT?o(G8XWW+Dy0fERSilmUwa{;HX_ierwFq(mW%njeM=yRdvFC4sshHvb{vjHTe;0F@=?}Y~%Y&>)WZw-BJPG&IPT`%h5h#Vg|(` zLXMeSibvxcm_X7RL~s0N#)n=}*F3?)R7PX%H4t^QJdDcS|AATv=({U>vOue`MN_G^ zWR$-dg5CTew^X&N-_^onxm`J1cW7C%26L9rXcp4m8BY%Uv2)ez=|r+qh|L@E zm0h)Hea9Xq>y9C`!p)QE_3cqY5M$EZ-O7GwpJ|!VNwXqdi+Mi(nsLQ^d&|T*5AThG zVANuf|MofSTa(6ckD&F#XL00{AttQ71Jhi**Or_?JbsU7`xyDMIc+$lZ8t0@Ky-dL zBRuh{{DhaPO&esZk|6lFJj@@H^8_t%(6R`@p7zv2aM=NWRgFL+Gkzh~fH(Ns|;;1~`J6 zC4!5krf4t6`!;=vG=o-<-(!K}c&W4Wgx_LJScsFG!2(~{m(I|Z1gg~yyRK(-KJ;`N zNIO8)d*{B^fZgzwARHrOCwB%MdzB;!r8dDU)*Pa7(rfoYnDBEYTXFObQm@%W`ngs^ zX0MaJrHShWAIssqHC8UvTP3pZ_xY4=sBwESIonk-xQynPy1GKUyxYyDHLfhyJ7OX3 z1E>lp?CZenHP519+0sD2s^^;yGcYPMc{VneIg6r|JO^#qw{ zef*SAuNDx-Km}|MjjnTvy{^%xN2uh}Tc=Ul`yMnsyb0JK#d}-6SUZ@q9;Z+6v=bUE zY44pPHS`QJPPYiy24kJ+me!^Rzqs=cWVVYYm*oZz>6JPr09UWzLOML#bF>B?BTt85_=NHi+(C90LyF*f7I%#`SXZG*9K5-LitTW3PjK`KED)ccd9EIa-9kvvK2j zh}bAVmN~ituohX&wIIKj}%hI3hu=A*azym zBy#B-%DePKkcF4k1M0kS2Q7y<+-PY|tH>WEQ&0=YMfy2a+6OPwCww}T)MWeSRuP9j zgou>EW19eWvrx|ljUJ>)wuIH{_;e+@)w+T!<%s!Z{lOS{YW6aJEZ`TSW%yDGUt|#n zs3@`Qahop_eQ+C{eOU^Tq@R1j(t#L}#?=YrG`^23>+9hNPPuh8ikIOE4IpPDj?Hy^ zj5*#aIy;kHwHRRJY+!gudvSjK)+n%5(tAx(5moPydwDFsF5qbS1#pBlqUCrgnlgRm zgQW+8{O<1Ohx|C>(Utr4t2+6`yeCsq$G1tg$Q^iqbRVQLp$x0JbvS2hEW&>y^D=w! zK-0wT@&jD>LxGBhM~rrFa3MEEcQSScZypwY@9XE`$HE<-2bp83Or+;ztU$B-dMC$j zvMtz>mm?>JCmSUPQm$)`QlxS!AN_^Hz!?&bs0m+!O4f*gn@&n8PxkZ9dQ}deLz?9s zctNm^RDDC-R4akb)`*7HYUktHE4Dncu!r_Vl_!1f%NBidV0cz&il9tbt*>m8(c!MD zj{H-UDmBQde*s=v4`CN*J~j_IwCEdlBsa#gXr6CdL=!kuQBgXTSh3ZcEr{eQ(SRKP zKLkyS;6p+KDh3V*5ePD?t0?dNk1c#SuqtP>KJ0*@%^M^ zI*d7K@v9zcFQwaCX=Tq`rms>!$W!w ze4DOwg*C)Udj$+fD~E-``CY3E;w)7}VYVfoik9wvO7Lw-$>b60!EBEDuENydJ-x$@ zi7TEHiE;bT*Rd9lZIEcS5y)fXw*Y@)@m3!im;6zVRpt~`w z*)^-Qrj#%6RQ=fBckobz&gP&LYr;zY7B}8c42P6?YwF zsiswZ=g`ZQ+@XqyFqYaW!^sZcf{J+K0~CoPMsAF#H;><`F3ZZbzf6|(TakTf8 zt1U3;F=E$S7|$;<^)jmsnM)PB>AS{qK}wXTVdlYuFw)_}A?n-mDgAeQ-X@ehisWh& zgcq1q?cEIQ9^i&IGRjWEcGp}mH;2!q9T5kw4Mwox6>^FgFA#}GF3I*27#VjLzOQ|Q zM~;FQ2&j7Lr(uD*hUpb)X%So*>Y}99g`CWS=uPYO8C=9|u`J?yDHTlqSRzm$eGb!# zYi-^8b*5JecT`Hk3w!s>c6u81M$yf^4?jmdoIa_ntrd}EebJ<~95r$sZQSAsNHxl%BTv+%6@ zH)bEALw8^nX2ZT9?Z-_av{@qEMaNV!EM0wGd>(&Pa%j{Z+OChn!*3fX7Y+My3@D%j z9s0XD*V&(Ry}4|+ySRo8*~68ULDX9OP4Dk{C#ziXZC^-EEVPslqtHolo*$_i*~yVL z8)ohMSXQNHyQ%&#`%0Fa33Ru#Gcdel`>L6K8j2%3AXO3GF;Qb=GTVOENy6y`M0@^< z8C%(+tLei-VF&=z;+*R{2cDs2jl1x>Jj}Y++4o z#E!C1P_psUp-cxuzRyO1QG)WKC1)s}X?a#6_f}2)>@6>0KfkQ+n?4NZ>X~!>ie6z!=&fJ~-i-KQ98ZUt2!sStr)0VMUwRm^mutxGIFbOONO*>7w3@!Ly zxE-k$tuw_7O4rU+9Js16;Qlz+UsDKPW(fQp-B7nl_lzvoBR|bP-?n|r#K_eG5Y6P1 z7i*LdRL0ImiZbY~T21{rZQU=F)9Fc*a`%mGUK@zBdB-NMm*meSdRqsku2aLNx-uC5 zmDjuEea12Cp$e~jNNuIuJ%x6+k`~#*2=aNIQ+%Gu-{&CUg0ZF9tt^>1L8&@JztGM5 zAtB}s3v)ZY3&Q?JD7Q{7y@jzP`qyj9FXV4WPUK1QED{Lt*r|K>gWW3L`Yf@Sr%%w{ z>sGLjTt2k9eK*rh!1y&qE#Z|wq_!5K8=!ihQS85nomhR0Eh-GutBzhGR!~PEoQnSnV&Lqx!;lG>^NHK z`VdqH(Q8j2NniB1Tk4AOJFbQxW7|b&VHwUrYn7iov$tq)VMY(1NIo(4r4GfjTXt0d zLjyX{Tq#tsTSb#6d0ikPM-rMpq$u;aIg`Pm%VUp(cBoxjpd}Hbq;Mxt2w~b~pvt0j z#O$W=_V|Y>R~ty@@FI~RVtc6@-T~K3MBSVqzlDwvm@k7JjY{gH`0lPzE=)3(&x&`IIR-`y!oMz3kd2U)N|?W6>aqOuluV z{iLfa__curAgE>&>!rs+C#!h7Gywf@`oP&cXBa(}Z-F+1OCL|=;Wnz1Cn}l53@OJpMB=YcfMy*o(LcX_UPtgfAx|vNu99HsYr`&z& zB@XG<5g#(hRwOnoS7fLfuDB;}R%M~r9^0+9L+t%5KJ+;@np0Ybi6=~Wl3ur&H<5Fq z$SWx5iKm`hr%e%!9N34Ce4vZX4uwP-UZye=He0fHk%3i`R@x&y<5xoime-`PBQ z^T(Gj;t+Y}d-4`lO~=FePUylIYJs)&=8#7zmD19XZq=+V-?vWgmv=%F0xoqi`RlEv z4B|(sPFQhqhbPAHoY3ENRU%ZoJt>(gZ>V8wb*K6cNCWW}Nj{I`J~+snixlu%OGX`| z2$U_Rl77$J(+Vuij+aIby+nf+r?=6E)ov|Yw`9%OFoRSE+w6<{OuIBZW2*H>yOiD& z$y9p7`W5i7z1&cb4lrtiAGs8+EK%l~RN4;)01~*2$|eJdjlX3iI^(?1r6W_N?xbZ$ zJxZ9148D!16abkIu zdg%a3xAEq6IMCa{uemmHW^jAco8yv~kG8qHUTFvi+cJR#FR=esxoD$Hy@!pslX2X7 zh{IHN`0nxsR?#GDbgqT4N|DByrVZd`*os??iDrD_T!8%D(ZyipSQ93rh-Ao9-cVF- zp3fKCieG2$S5G=`VFxoY>H)Zr(vZUHF&8Hnm%)Xe492L(LZOQb3r@#J2T>1nf`Xb# zOH0Y2LrzYqR8wO&IJg7i$+LUQxX95zHY4encvzj8Nly}~A^Qut!orc*Dxbsr#Zk5B zi?q){FqHI*(y8J6oa3kHwZIx4(pT+sp3Yj~3eXZ*h#xR8iYbsYjlKk6z0Tw|$7-@HZlsPs0&SKIPz1}j`ZS8xu49D@Y0pQXA zI9NN+uJ?7v)8+b^1ik|?9fKoFgnoz&9i&G6C4Ib#wB ze|>PEiddg~p-CtTv|%)(gb7DEFmgM$bA|ohqxcx)T9MK+ihfA2*i#d&>c>r+6?OL# zLIiu#(s#{i%Zq$;*LWy$!xEmyf7A(N5TvDby4=e`G%@13tVn*7xhl2&F?@=SZA@~l z@L9Y<_v@f(_-d8fwb!qu`}0T}M={tEPv<3`$~xt^-OHlwE0+Q0OCVYkbolpiT<6eh z(-2s=S_K6IW{nPeKB!AJot@6fhjJS;0rHwMV*BCf)qFnDCEeF_n(nHYdxH`>H~J9~EFMGS{>g8~8qjB9P8$l++4y0O8@$?~wH?;L?^jf>k;LF*+=O(tZc zWuN&p;<}(IFWl>!>8CkUdzY@pecH50gKWSZ32bFKeYS|uv~{9_wx9ijkUL7F^fB3x z-uk_hB72ZFe7L1Q*Ju6UFf=@{KeNk2Rojqe-Q@77)HP0}7GMuWtgCDm%LIAEMRHDO z0B=6Fpz7UpGDfY-?pa~y@>@rMpS!-T3B*bs_du3ANl-h%5e&w^3<8z|6~W6eQf-hz zcSDDDenR~k5>hH9*Bv`i03B2FZg)M&Q}g6+i+1y#9^wp$#*J!7U?; zR5c=0?QOZ`)T!-D8*LEll=bq*V`AWV0w#ZouC1pvww2=!Wx;60(Z1bnddb)2abBCV zy0dsI<}OI-C*7djJxRUHjOKzdzBup}lM~g_{CM_ghzPKe_W+bw;JA^2yX6YnVm|wq zKHN8c$xt#;yyn_R_jIh-vg1b|Vs}~ou6M9GYU_OnIdID_Bn06I-On=*-cStL9#D32 zcQ*io-(-g!2OtoLlHy_&x4k@M>blQo5p~C1elXT@F5qHvAWOYu+PTNNQj8VL^6!~a zlw)-y&n>obfzm2DnHKQfC7BBHG*4Vgi!;m1>A~-YaCjK0 zFyAL3a6V$W9-j4=J1^i?qqY={D!szdt4@T`PjS&Rp>}~ewZ`d1~FWpe_*#9Qzr`* zCvv=FHy+e*8bB`m<(xS|2OX|NCbd`{VuvaY%?M_K)eaS^*A=EwH|d__3QKOfj*=f?f&*B z9=q#}UR|$FDe3w$vE4ecFY{SR+R09bhmmQ+z4wy#{q4PqWqQ6(HrW*4#~&ml99{N- zJlNTY{6bifHJ^6_`>H@oLf))u+ehxU(}pQEH2DdxmuSp)G~lK%MLiq7KP^cIMWhS2 zeRr~GlGiNlSe&#;?_=9NEgL1>c(GUY>gpyY84a!zcCPxr|JFZTVZumDSIMXB8=>%8 z@6`>!7>n}n+E18UEFxq-X2x=QoE++`Xeyo5(eB!MIcUt=kbZnm+}ZKEvOT|bhtLZF z56^c=*d2vKn_&KAC_WaeuwN-3Efi)A@I-1|l;0nh9q+$0Z~Ei10INnWDAe^h$HebBq_igfxiW0KA+-O%=rD3CK4N+bDz|%||j5+2( z5S^zn=T5e&+4tATN2ef&llzKLe&wxDAxiNroe`%zo*ibHqePSZiCWr=&+@)>_TxhY zkK-`fDS3};^lBd&0>r6H=w`8B@@(^j5Kt=6iQNJ66HkKjz zhCVzy7Jq`?^6+u+3qF2d2)M+g?yJOa{qlKfhz0Z!(Il9!g*sK^%^Uul*;hTB>{=n# z>40-TVFIl45m%jVbPFc}(qJ-`+gdRyT={!bL6AO~t(d9cE%1dG4-pLwe7~ujaAQ+a zuKW1;5x#wR5jgXbX?xPEucWngN{Z|Y^&Dlm2Tp4vUi2!B*Atcc<%)YhaOa>+>rD z=ruRe4=+(22{~Q+O3XQAg=Ud!qBVnp z1XQ|Pxe z#yw_i6*4W#y6)#}Av2J)`uaj6s}wSKFQhwk`jXxsPJ4R+riT{G+x%*P@+n6mIYc>| zGB;4tSn~oqP+)J}(jzAOx|i1;`?N!GdRqT8&L zMH){B2LI3rOAg+3t7sKA4zs^cI7?wk1{aX=Vo_1~pEaJQ2<9xaW>PK}B1cZ1e)w^`0emWj@jg92=xqph3W z5*6Uf=;rShWH43Wa`N7Gs=<`nWN+h--lSs%zFzFAebAF+-$&y|PA$XAShjOp8kiBG z?av>jf8q>4%Q+H8dm7PwWAh^wH;tiE0!jC5lQ@KHndOix5y!3%85-PI@9yOvQoJxQsNc30h_gr>IyAksv*aG;r2fZC44=^8-Xf|X8Ct$q zG`;w7MkVmLp4_{uIPSNJ-DA2vJltgV`=s$xtvtRK=n;-MWbp#Eq%)Sj5bs^BfNcFO z$Nnei=Y0*JPz6`E>{zEi$+0S|bN8N&(us9?g2P(?D`pwe7eC^PjDb{DRDgxhN1i#& z5nRbhNlP)Jz45rvCD#kj>1B77vRr&4soMnGgl(833P+Ltks@YB>Rh*5TKwipzvTpJ zL7-5DG9ZwuP&M&?Z6n=Rrrf#M&2VnBv>wSYZiy!K{{jAY0UbLtJL z?N1p3#`4#@>92?)Wc>`DG@jHx|KwRLjP>wdn@+2Zp(JCaUjOAb*}un9e-Z7=^XL(6 z7EqcWf#zU@zYF50_wgmn&k19Hn`df=??DII^nLQ-COu{wIc({sa>g7t=ybqvr|$vn zE~zQ=wq?QEE55wN>7gIhp}G^S`oTjnF39VK#0o*arIz(owh;NGu8bmnyY`RjB8bBm zFG{1rlr{ss503J{Jon?#t5=F;cfFp5HmLFk=1}DguZ6d%N7nKb?^AntO6ke7 z#+goJGVuM4QI2zu#*v%>rVrddg_U&VDKZLzW7*ashTA~>KP~osRAJd5aNG(O{&o|w zcVg>OMg~K+NB47iHQ}W2g^3p#?VyFbmET?&L@v|r^j^JpwY2HtsCkV*V5|&-RtCcT z(}khyY6th5hvfiRc3H=%AyEyoV1vJD@8z*iX+A#OLlOp<_VBUtBlu&cjC#R?bW6R& zP}E;=1$1DO*^mIek@d5X?Z=;iUz1`UTq7g*v;=Z@nu4yn-xqyd>w}9r7xtiAs2e-q z(cbZP9E}e7rkT+OyW(u}TsgG^%Lz@VBPPQ5sg@9*>aDc#8;Ra->2wz_Pu;D!FY5Pf zS`2eEj=6F2W3IpaK#;M+?WJ+k4ze&IWRQs9PjK*b-Y7Z8x&^pU`ZW&TaeKU_k9g)w zo)@GqRxbtmh*BDH9D*KpUDY@rx7qEf?6%y9Gb6+K=1`^8dyUmqa>qC63^sK`#glX1 zO8odPX$!5)USD0j$$iV!?Tu_0tA;@4)rr1cc2W;irxeHbT6Ue4FKaDEKTcbv$(i;* z>nFhW{k>a~gWg;p)i<0}v^aJ`C;T3qqIRvV3%a_y5#D%hwy>iOGO$@+QSqhVyKiUb z=jMtlD+OJ5?(kb+QXOeHQPc)OdlY91+xmlriMBIjec}@cIpE%1YMib7AS>k&s9IrJ?#=Gc&$r8V+N^O!&A%P{gcHiBg)ywvM<$H}f%a;+=E}ys zxmU6>C3%f+BWPFS=09!9)>z}|97Qku#0);W%yq-bvyYh|oR|#e7&bwVjvJ_{MVklA zGY$<8cLBj<$Tfj%{`y}snHTG5NGg&6+?$7_KtcslS5L3VQuy9eLt`HSoCtMBOk0{s zC5OPVf~h=n>9|`6LrkdFelr%@TM{ITs^ETJCLPI?UEmb``2G*8^x<~qZIRZO+nW;=eQ-RV zh?qT(to^=D(R7YTa2B|s9>Lwjt88&HXM?N04#vON_&C1}IeoOYKxdwhD zMj-3J1n09+_%YTgYO_$QC^z}4-%(iy7f@@@uaZ1Kv3w#09g>6eD(MYwNvn9x3ZI;& zpn6+mk-(dAB;7K?U-*liPJ3rN@wi=)xOud6kGL6-xI^NW+VAZG%YwGcQ*sqBhlqc8nVcMAio?<&7f0%Up-sOIXT%HY4%;H$Y$O4 zJS2H(!7Gqs>!zUKz$#TUpN}fEq@m#fk6BYJg=DiwKOnx&aL%?gt@)yTVOiXqbGTiwXURP#hWmVMm zN~sB^sx44pJAEkI#tApazJbV*aklX7GwE$3c^wTG1_cy~>c|kk=zrHTiK> zk*R5ZRnAh90{zf%L+n?{E&A=v>?t8fo<9QX4|?|X-nI=@>;whbpToP7z3rU-`8ZzG z4BbN1u2d&*h%`Q))cgNFr8W1CPgX(urYeS*uB*10k^?sUIeqXT^BZd!&)PK2i`V;B zX(VGxEgwKgjgHh^elVxgNJQVBg?>E7kN4E`#{E#zCjn6wmunu+kW?q7BO#Bk^Q9XX zLW@j~5Ausbx|kP6&OJFrZ>T}ZxIgT;*d-R!cBZKSpXcMnH1-F685UurplI{Dh=ADz z@Nf(Ei3$En$%j?V4GOLO-3Wcs>`<)St+aQT*JsVl67n7&0|az>c2S=rCgza;k6Jp8cF5`XY6SuhUz8@0uwbN1Z9OFi`S|B zI~X?o2<|68@a}-7wf@FM20X^!2e#%WORTTzqbxkqiT2sL+`D9b<1+ru2w`XGOn2@r zFXwi}m!;~#1g^qm0|G)2pgvSJHv$jkpo`>_PpN|RdGJ~1%H*;P9A{M+@AU!oQlIq7 z!VCA951Mw$&_oPn@?Xcxu9SSehN%i{6^8a#pdsOjYE)(dK=f+D*J=jZ7l? z@(+dLvN9|g8VwOJL+P@xyg;jNuT@@eX+oNbN9u?-aT=F&Y68u;If;G0wOEV@g!QRV zrsu2L&*Mv9t1~uPOJ9#F9$Or#b4S8-ukeurYppW4TZn@l!lDT#=cazP+i{F?JIRcR zw-?(J{e)CC1P?ATG>;RMl{KNFV(ogB6+wI9(YQ9Yd}o5P@|BN`Vc#l$pj`uKK*!d-G!6m7#Qq}hakYkqR?p?~mn&5gO4R|3UDmw4k2=rP| zA6wl`Y8_x$-t1l%92|7ip+sbQd<014-6sz1&5%LZ&-4roQpLF!x^E~PunS}bFKKqY zdi5tdfhPa=o>x~~*57EF8P0(4(uMJPD&{Km6TXVTAGZ~4rstsp)>yBJixT!eQmKMV z(lf@-MRh!Fj7{jeEzAQT(79}%Y=rY~kBF8KxVS``XAo=GQx?yOAo-#{9?`S8TwX#{ z^bm7;vc{$RgElaC1#$*&)*no%vDF5!@^w}3xMBaq3#IIZ5Zk43H zBMS#6mobj>8j8~=k-A%MWu=EyfjjgV$|mXU?CDC75v{ExjpXbezi+Tp0}_97)B;{N z@s&RL6l^BOd0kigF=Aa}sn1bc){N%XJV$ED_sIyGQD6X6;n3H+tW(NE<>R<~XF=Wf z4n&;^q0Suo)W*^dB-1(5L(PD^g5(+!4)*0FuWBHs_T}p%O1vN%E@S^(*YE_WphqU%+ zHmSYx+h#IjDN^kw!nx>{QMrW?I&McOPAI5Dj)jO>@4<@PAP1y7GSDCv??OHxnG#~I zndi7o4S$r#bpf&r^&<2?dfXBE5=IYPDpmRUz@Sjqt=UwM;sYl*B*Rn;?cOO<+1ubP zdim6D$XAnHC7Qgz!r;%aikro6t-YK-Y{RIviB788 zk7c{eV5#5~k^>Sz}njc*Yze7QB(c{xu@U{0IXTd_Ndj#ut zw|S`u{fCo@?C-ZK}&D!LEs#vr!8B4W8k=dc}GA!?$9vXkhDCm-%qnJi`}m2JiP@lB99ekBpO9@I$%r(Xz=-C?=T zncV4jT&RV^7cx{`%Gy||OC-Jy!l!CIoZOKQ& zNN~F%i?O$Aw*J>RL8hKA&45^NY`u|$&ZRxPMs`1kDp7P00upJQU&dY(B{N-va_2#j~jQi(B~Y)X&OB z?bhHwoim|Jd6C*g+W8UZuu35duL8uuTMyFC$gc-`!=KM@)`6S}63Ry*+UIYYoyse{ zE)c`ld$vj+wYlypJ`&-)1(@VS(qo`HuMtag+5w`OAi`c%opkZY)O6LGbunH`DXu29 zh@M+xtk53zit~zZ9UqZj83&5$TGA6m|3E3(J*M%=B2J`;yROyyvP)t!-*^t|Y32RH z!6Nf73RzCIi916f0tXl~yYg0$w38KxJ3v=CuhtEX>$Vs)5$HC7Qzd7cG$HL4EL?;~I@Uv|Q^)_0Hn{^AztXiu-|q#r;WB?@GuYf2N3H_vAZi zFm=(}Y4m`r8l~e5Ni1s#EVQ76cpD{~&-%bw?C=o*guD*3`JFHIB1!|?4 zb-9D#)IR-_V%`Ej+-{3cxO7{QUyOwoKKC_#X0t0Lf!jo>!G%S#w$WNmNuyjqEu#A- z8^PlapD1^O^4rTD!vCeja^5z3&ujb?aO1J3N`u4w&gv$c`yyO$WzPVccP%25=AH8i znd~gFF-y!%q9~2KU%A?bn{aYys{7)NKjNhuO-t50|`ua4qMZZqCtM5rCwR zHb3+VIY_@*(Gy}CM%n=&IRcmRk|q@HK_^sq7v<%P&`0wnpH?%e*46(-$-06ak8c1d zjyG$MD}8$_{Esv0pA}NF&_)z_gYhZ#=fbTyPHp+$>DfJ7uppb$nLF1{r^WK4FnFu_ z7i@}L$hxgRHPk&jW#IQTddaCHu&OghnEVH; zYFAeLC7&MVMY5~nQwuGsNR6$*J#U5=f(ePE50JCwT?!ts%tU zyd^^topsQmKVEPYPPzdO{b%hn_|+n$`HRRb!Jp~vt6801db0XhQk+kZZEjdxQ*9)X zd6zU&{Xp}-mcWZl6`B|SRe|E!)PrlM-9Ibng$toSj{m)(D84b%(*V<8T-b3kTTWPT zejZ#S{?9JCuk;w6={2JE`pd=wDoF#Dd5%Keo6N&IHiYL&-wEjX#7L3%0+|&~*l`-Gwe@T?1(50N>gRv)bua#E>RF*? zH@h?T-iHK1}jnFf^JrEDc+_BsZNT6MK?r$)h0J- z6Jhm*%&zZ)+SDoXB#BZhh-V0FRB?#`flKRQCrvT=RdR0GOZUmD>x146gNlPr8OJ$& zsh0rlZEBq#?r^_MgNCgM4GvmJ|I;e#o7sol{Lb-!1AFp+$L=8^E#^V1Vk~4{`vRG% z!wm|5@O#afQ>A%8mWbS=s}Ib-A7J(>>f0|$5SbSob=NE&U|s__@RgY?G|a^h8CYG{=ZoHXe}K(B&>{17Pu)GI(@yoP4#zp9#$?e$h66_DnSc(Pc((spe`%EeRW`?H5X4ubi3d8@_$Rat4Y zH%l5})fTbA;^0&kY79B513`87Yr1_pGc)B@Ik^K4JCgi3hPy0ujZ6ftI}N2fi*=BH zRzc-I2Ep3elrlX%+oZx>)|>~#cd&vp4zaSz*-d~pldM%``gJw)+IjZ;ZiS|#gof61 zmG^L#8q-w;Yw8slEUm2zj@84@B<=-u&CV2#bH-N%CSoqd*J1Ftk0KNSu~; z)$Tci=ITt-fpw`>VqO=C&f`U!K^Zyw7P7nAZ_=Mb=X7UQ$Xwrae%&m^SsOyu`}QPXF@pH9cqJQvWH&296(9Vc-*(u zB$M&UKg&S=O#?s^E*lg1v@*0VJJ34E`-p{TV_Sn5xn0Z_S2?`LQK6q$p-#n=U5>|k zGEV80Rjj*V_J)1?5puIuOmiJUNH7OLK% z@^Wg%oP;L7@X+2R@ulbbv?*kJ%BxPi2r<^QNnkQ(exkbrj zI{2Z<s6GGBrnl4Hp8&wN|{!6xIcDeb$$oWq#@oYG0RrAiH&6fnuE zrye!D407WDbWX^_$Sc99>#)&@=ZpS|!So5~r*Vn7^e;`D)rD&X*z9 zV3Y`eVP4sK6DZnRbZgar?hbA%-@-P>tis*G5IUM4HW7#;CBAV=Z+x_eYj`{`LsFMy zD#pC92r?X!?Dml+%VH!_=cF(}~Hz9h3g5hRXQ4FS@ZjVv~9;zD4Z8EsqT*5ysuo+$F{YeYs+A zFE0=i~wzZW3rTGIeQP7^Ig6B#06wi+*0$=ZDX z`aa5<>%6FJN@x6RAbod*jmA2~PO0^&EQRKH8tnvIea8+451$^xp@ z5oLn@4|(qy)>Ia@jiQV)D#9ot9V`^-Nbjg1y-Da*Kw2R5(2I(KjUv7G4gnDY(p3}$ zqy|DKA|NFMqy!{Da#kE?#`pcsb$)z*&%QFwOoYAHUgatGx}SSxng?_+Bg{}1ePuQ> zN#Q=gotPuG>hi35b*ct^(Ie=Zgc zm6YM_^EpPiGLd~(~Tt?<=a;-~~sMj-I;dvyMGTs_U(ePy@^?8cz*K&zsJTGrqJ_lx_N z|44u6Az#Qsa;nW-svM@w$thR}Fv;Ov`ud=_acp%-NAN1NZ%!O2 zdk+bXY_29;7r#MqKIQ$Z56A}lpX)_*BJYx>rsje#yKMSHdx&c<(DYfH9uWcfMMrYv z*Ykaeg@`Gng53&4->BWREQz0EuvpU7ro4W&1i9XTt@HA)e7ACEu1-_1-5pt##oyho zK^nI;`_0EtP;|*>Z~{3PAYFr#v-@hB0yteIB^AL5*Sm37k(?gk`Q8rSG~*>rV982l zGwu7fh~TSbH{-?CoP_b}@1ld&_L~yfNzK?gqLqdZLxm_$k=E*W{=nC7S)D4%ttSfc zu^xsNnj(CqcU9s=7i)?u{&_j_zdb*T0Tq(Zu2qeyfO}0i+6ez2G7I7YkgbVkB;ozC ziUVlPPg8waTc+d&5=Adet}@3=6t}|OIWGua5wu3CnsiCgjpucRdB#eL ztM|W_F_%^_c^4^IK8fZI1onMq3>w4JwkZi~S-Ob8TwY8BWs%wE1zYCM8-1!?MPh~{ zst>rF(!OQN7&V^mF|jI2zo@}ug*a$yGeuiopm>2S@amC4i6eds-?(!(4Zk>x7!+ro zd3@{jgd6&L>a}SfU%XYr%CnwZGDpEiN18~Qd!L8s8vp?+N9ZLdZXA;+9#&lSqko3X zY*}P4MHib$maTTg_4HfjO&Nf7lUTrI(_)?!XkYCJF5tvfmJ#%Q(M zaIr~woq}cYW0Topsy0<1Bt!F2ysz#z*^A|CZN<(PE?h_jkwA^E0^U%8uw1~KrB;3m zVu1(bxSSyhow+WdF)p9{q2vX!GXqg&WvBh);)1!e|HhO+k;S9M$n%+rvnYA6o;^FA z<<-eVj$aH;vXznN>-@)n)8crBd#lyI1sCynhP$iPp_N5{q&p5|3r2P1_4oHD-Xwz{ zbTa{`#EZ@day6J8AR7J)kF*8QaRys3SDSE{z2Lw8E2pD6{x{~VNI}8<5Nxpm z9V6H6)Yp*-UmFlU)8-!k`To>5m1DFhE3LZ7)XIjg$#)S{Nc*ms+0#1l7ykUi;3OGZ z5E)`yIjyr&E@Fo9HJz%Q{~v9+3j~^;)-Qx3VCbAKbFhw8$U%l;%)MNfToFh}&4k<2 zf6*=q3Ig3zmGfHipT%cy>1^JhGf_H5A+2fJp-mH|OcNC$^53$)uMDQhQ`Z-0*Vy^9 zCO3{zFh*65rp2dtN^8gcw=&<_KBdY41(rFkFTK=c7d|BE1EHDYn^J$hR9Eq9&ieg+ z9gejV_Zp@EyPWVSt&_4U@;HS^ziGn2<`nqZInUJJf7Z5k!VPpL=+gMb$$N*pFW=MO z0m(<>!ui5@f8`hk#6w*MG}b#4ysf&@$=;3++2@a@hOA zepBj9_y2HCoSY`k!_ue3K!GrrB{o|bQ9llmzum#Ido-<6D^-L1Xhs`CaKc}DxA(?= z6RuH)mty0Nkg<}@UGrvErl!|E_Na690wJv4#o@noV;}?yiqt=wnwl0i11kIM`G_a4 zfC^Vu8jj7QUxRtSVCH3$^cjlzBwI#w@tt>Ak05*aA!_sB2*?(_p2C6A5T(9a^C5bX z_$vg+y+v*=lgH|>rhfU(Ws)Urc;Ylg>Zi);gpSbGI(MfxQ*vmtSky~-Yqixqb8_4tVV zL)d{U6-D8yM^FuMa-I;Jg{x*;!TV<(xN=m1rQ;>Dn0ZHE|89S_g2mqxm@E_aeVW*PlluCOUWP!D!v5byuuq|c$+L(E1Axr5bM{`IPF ze>f>LFq0bEpMOBtP>9?yi{R_NdC~P1m(|T3DkjlBV1^wrJj#_mk9oUU8cz^1spu>Zyetq&teYr+n z%D-+RjFmC^C{)}foS9Kvc2**5^0iWKa@F!}KZ>_+w?tvG$PvUM{jAC-Zg%fG>7H8Q z)$J_U-SQ@+8A74V>;kpG*djV}Iz*6{S8idmLGfageap%8{JmeSJMuGPyt|WbTzUB< z>}b!pwWakrSdY{G)$Q#w)&)Az@JDGh%C34hGMKSDQ#gqT zMPk<#^6L8MijnPR@JkTh?G!FnYDYliG9okpBp+$#W+DJ&eD^o4C%v(kS-97{(RSdw`H`Wbu5N@3t^9MTJzjXe0)EqC_YVCZ z6p^mIZ8yHcC1eZ}J`)HBvo~|#f6}0WsY0XViCuhCF>G>)X=!DRK1=2Y|J(pbgOvW& z{)q)>;?YEn4l8&G5strgc3F`G;#j~um8{oALgPKUY{D%f6rP4r>$$!v8Q9FZ<_3br zJAg_fDk}QBOrJ;dNcWNevFP5Mx>%#E@z}b8CQAAYb-hLs{vEtv*MaoO!Bp#q!{f z`{yo%TOo}dp3UR^eb0XT!SUP;{htG*kWOc-VDJYy5Mz()s(z=PC->@gGWPJ#^`%tu zj_~er3hwNuTO|44hYPf+Fd&sXkWl;d{Hu54r3kUk8lb`8RbA7wjNXJeien{ z-%T*9829R98wy69h(H+IXGYfSrvYxTctZ<|3Z(F!`}=L_zMyDaRv5) zRUq}JVIa+_yYl(}!)9_@D-Mh60Bi_+ez!@fgTq-`p)kEwTu(t8DU}Caxsu~a+dK` zm1v$|wqr`iT-;B40t{{uM!YX^3{xJV!+SLUt|gzh>E5bW`mbANg%kJFMICxl+tPly84<6u-z--{t6z{V#ZB2Rmp|T5 zSC|mANZ+mi;%)VW`(Y-dl{ZTF6e$_A!)+`}>3Evy|4bvyE77jv6v9I1A9kl_&g%oF zNqca?ZMh7>Nek|Bi+-y|UeZWcQXaizPa%QlCN5 zSI6L5x4Tu?B&RFGecJ%192=@5TQ=vjP|Q2$A6_ZKm%{Z_IM}GqJIbDXv*u@|IIj}w zZQi&=biM52^!=yui_^QfDI^?g ztTB9>!K(0#(3>0EO9OfWfU^Un=_Ucz24w$$0b1~DmH@Wnx{gb15z=*R6+q;}J_8CR zWCm9Px7Yb!L&Hc5CNkRwq(95AMQtfFjw{9f3-g6YTj%($s*B+&c-T!>srwzTNO$$C zNBowfXTsnouf!K3sG5+Q5j3sacT(U#9{Jbl$Sl2{!V8P>`+8gFt<{7|a3N0~pB$bp zW!S?F*~`_hd^`%StFBdk;Av{6n=sop}ei_>SNrgOhuX`zv+v?+VKbXQGjE!5P>CisG7foG# z(kFUcyr_L+ZG?^_?~G%nAIq{T-Lh*ynicb&xK&J3hhN@LrwP@^1Y?BcWz@9vKF7~U zerGF({kd#XEy)!Y<)XQ^x4}*4a7d14s&!W-v=&K&}r9i4s)_*H# z6T@KRB=Y_oL^y0P527VTh13l>^x0@&+x2=Ss#C_}R-UI&s52$Ev&-HNSAT{o3EEkS z)rp_7Eo~Dl6r<=Ti%$O+dDLi3{hHEC# zLpjhgVK4O3=4OtZg5H_pU<3Brd))`w?n=dr&Az&U$vw|!dVD7VrNO0#hR_idT4KYZ zRolH*ZH6L~!P*PPY{~CZ{BQJwJJl-eo-#&Y;#K!{s2$)h=?*aSQIhvdPB5DhgJx@F;GG zu36Dg&2x>iX1RhyiU8p&wSkL^%h#p5aq(ThUdU76Pc>}?8&nlpY^;H3n?u~==qra& zU$aZ^@tpKkRkD3P8F|Bw4ZBrga-e%VX|8VNbDA}MjGZ&KYhbKotqaVHYkxiCcBwo# z=;PVNHttOkDic~-^ifs%oK3_B=s4^BR~=g{2kugVHSAQ_8X}%Mr=ES;_=EUj9gA#e zxf*IKcDhnC^0JY3_u#`7c2^S);9Y>g5@qPRPe`C^gUw6RI2R{KtmFpU8^Xxl(T2x_ z!zyCTp1w}6zstm$n?ob)S!ywqg_3{?7sswU(JWVQAj?|iu4LFQf3RyvHY%hIJG;1_ zd!5iMjlS?kOhT$%z2EdiS$+2X?Acmnj{04MZ&FOJ``E*;=L56U);-R)xEtwcFKQypk($l^8})uX zl~&Ehv9PPo2nbzP2V}yAUV|eLemlDw{hc}#_ zzpu%m_u@?OMr#w1fQ78j14JCs*vPpj%a38Or=BC~Wo4iYygVJ*z(wHtD$t-Kc3SP5DMyudiH=j>CuZXl`L&J# za7~v;x5&#jPU?E?3<~Xvvwaz>cd~hy1ty0^3NVK=C4vV{Ba`&#GgFiJL$oBE>gww$ z>MYaD>y`G2YtDpbMq1r&VZ)oM#hkou>4cJMTXoxF`b8dB$9~S}&jnF)`Q(&?F&Qv; zteX8a;(?Mg*tuNLbudTk{`jxYg?CxdFAISV zr&D&IsuE!+whr_q2$aZS6&h^W?zC=%>Up#u)x(hmnxL#MY_~RNWl4$DSnq+!mS*2P z_))-dq#mn84EWksLPvcPU!ruRK?X%O+a&hK+3Wjddff=AM)_?ENrm zZJ&~LQ~BdoG4o{pP05VQjjGo;eWV}GIo~ddw~R{8@eM#>bo#xPGwEI@0=Y+?dr;Ia z&1ly;Ccq?lBi>eB;WA|OpqcQ;`0Om!#MNY{B~-WAN-T;=QErkb)cHJ3CSl=;krVWk zud9zeK^SFm&XGBXi~--b-3KpSCWQ%s#QSb_^Hfw;ewI}MyLqG|3<*I=Mf1tM!4+Vp zgmwS+3J&7;C_+Q{ffQiLe-hU@YA5ZH3?UGED}^~k!|ZT^o?;oGwjxYxZdI66qsb03 zL04AeSs(IfAwjPkf#_>mwQXL`e_rR<2TB}Q(2FkQlRCx^73^YpK= z%`T2=kn320@-~mj#bC@qb}?B1S(I8?D@7=2YUUuX7;FU4Djj-$Wy}oxmezzvTj0sV zHD={+We06P%OdHFD%N?5ubiBkqB{criIQ2$EI4uI;-?i~^Qd2IgfSA^FErAcBO_E} zDWfEOC+#Cn#CP$qC6Nfe*RDM1R||t7p6l{T#yxRaJvy4XDk(VTx!^d7bT&?YE$SxP zw`}OTdHPlq;9U}O6?64J7$#2@->!``Ip$m!8lqcO{7r2DxxRyU?m)kkW$;Our21s+Glk~|mj?4BAY2~T%JNNszN*2oBV&j0%9)-K0c6G6FUM{M6 z7G~?tb@_6P8jn851D_vu7!u)r)S(zk5{6F(-3_?hP=MR8lfUNa#5YDCYMmax>s!~& zu>6_^8zF-FLEgxtV2#IYn7F#S+JOzd$(F!cbd^;Fh`_)g(lan%d-$u@=+aL`bp#!5 zc8v}WUg{9p*&q=&nznlQjm*ud=E-GZQvCzh)~L8JV8GK3wg>!h-Ymx4x&m z8~sY85F=w_ZnT6BWI$oh^2+C(w1E&+8^UmkxGP#NRzucew(o=sLs6}WlMj!G)9EniO<(ZWM zu@fB#fCG8%tyJkHt&;!AV9?Vwd{zdd-jTC14Xlyl`b2Nj=JoC#R$s%w%2mHPWazXZ z>WroA-Xk|K5Oa0&3WV{^wAW0LlT95#`w=mUNkQ>v@zR|9y*{fw`}=&o&$E5@_LaeG zIBN8%=$465Y!_?OjyX{n$;T*(46L>Ri`}gu^b;yqP~p18;zt19j|{Sp0Mm$2?i6g! zQyDDccP_8@@{Elb)z6_->&1KwcscLg>k_MIkf*MqV--asrZEgcoJF|w=$8${BML3$ z6dS9&xf9aY#mPD7+sg>xY8qyPThzDsbCT zVSgbZz7Sx^p<4+#iMBhAN1=*2WKw{}FmFj_{`LT%=GepomN7D9-&?~#Svdmg>Ixyu zQ+Es+0Ze{AR%v$f7x;RY_0%J6L}(w_wU{!*T-5#}1LQ&9`s0WM>t=M@Hx?0bv2 zAcfz^HuUkLoRiBZefMIkz_ciam>N;8*7{auwg*pGC6%{}9iirJy|Qah_&$3tA5NHU zy>UBzTf4Qfw>q6LXEdkb*ud13dqwV?w;8g|v?S5-lOrP(cN$v+V}q_PTQE5bw7x=E z1zS86r3PzFOG}T{@@;X;Cq%7d5C~>gX;kZ6f|p9QCB|&xD%#%;s{iObk#KO>)~sk$ zjw+!co#|BT0OJ%7#tHVkjr+DX1o!Tp4ytjkAd9fEH;WdgsW~Lo6CoURk`^Xi*M1@i zk+^87S+d0&*2VA6MRD+5I1N%omv;|Ogx-?%s&(v(?{kD)yD+TA5qb}o%n8mCD8uk6 zPfSdBH+C+u1cI^vCE{i*h(v&ngo&lsW6MzH6x6k*+=2SP(=#upm^Sz!ARUX8Zfz)Gh4NT5>=TH@JGgvf2OS^IJv2e&u2L}hV z?bCxbPw54G&;e`CkMpM=pSLaQ>{LAwktp?RT8l`UH@^=G*}jR3(TOnn0$={nT}F?O znjf8{0a5Nf#$k|xF9ze#m(@O`iM2e1YQSb}Tes%kLi*TZ7QT;z-qJI)=i8Yyx-KKz zhY9!&+)!&nyTv(_;e)ZfKu9b4GH-HrDuU^;5RrQa|^-IA1Ptli+`SwhsTtg(oT`_ccl7z~l)<&z+xyDjEx2 z-58Rl@c3^zv8rL2R_%cCemUhK)}FPCzXyYskaF_pG<|{ARzn zPV3=TZ+ThSOU|s&-3eJ-9jX%OQBcva#&H6mT;a!;h|n0lkbMw5Wc;aw2iVN{r>6D) zKxqXPjbZ{CucrzH_C7u4PnW$F6slIz6miZ$8YT;}VgfHgSmX5swG57uI&OrH-TJ$; zcZ~9Lr@*AS7vN9_iT9AcA_(T<@Bw`0w3;^gmvBEuo{`ihdyzP(TkJ_U-OM%PNU_kh z-2Y-*`WMR}gSy+F_u}I`lrbLrSNm1B^wW>7Oioq}bsC@zTYV-z#t_4QHS; zve3KnZhZT_`?z?wLfFIm(()@dW-*0NY(`^=H>9d8%aRJ(rb{{G%>|Xlj)4uKWN>ak zMQ-km_?>rD9M}G0%<{O*3Y!hxvpU+04Nv4WGBjMS%{i)E+kJW9`D?f2%d&(_SKf2o z{vneA{*kPlYeO?BNvpF1tSP4JZIy=pVfiXH3f1}yS(ydh_N@7g>Rx`nHdl&A#NH=8 zBevB-rdBCMb-;ip2xQ_RF}8N~G!RWUtzGa;lJ?;zcDY zR?>oiL=qc!XR~u%7x|;$z&d$jI6sZjMBh!Edj5=@dpLdpRaCz?0zj@qS!8C)7ACx659i3 zeEQ2>C@jkS@W^CqVwMpBvREcr`$&_V%nWj<@PU|Rc^>h3c$Uo=-`0_H?(U{QwJvXW zqOCfRJGLH|3_=x|ahbSy%Tb3`x~(aTQ%=;iLZ(;>j%D~J=Oh*|ofyJW9?(YaXu~Vu z>Kyi)Z^BGD^eqIaB@l0mTrbj>f;HeRhBFT{sPE6o5-gd~m$rjqAIxf6df+xI@)QV} znP3{2^a~CjmJM|k3JjEmr=^s$v{fs&XypI1u#>5o>S@z_2qc2C)=e|+d zui?`Jfr;E?zF7R-wHLKFP&BQCkOZXHh=c;E+|QKd7{%M%wBj_sRWaau^7F4~3IO%$8}k~o1WsW=CY48ni98e5u|cQev<8AHTj#6tdii!-}m>YlW{1ey3 z2wBkjVi{Oi@NY59JI|w|c^^E{>+c&4x74rX;&J33m}m5UOrK$UZD4*do~{g6n_#mFNQn!-1B|wIJc@G!>2HaJpJ1WT@_60ge?U7H=ZyU>F zNxpz@RN7o@sy&0KpgoZ0G;!hUgF}hpvG7ml@R~5S{BncWOpo2OE(Wu5uKbWMStl4m zPrP1O^eI`=u-|#J?!G?MNh`Ym{G1~r*#@??yt#oP{J0>f#H$80;Q=B|b@o{6&P|W| z2!Ogdl~0X~oXdh%`^JOwDwOp6L(2^-k}tXv>zv;JM;laKrL}?EV0pv5zCEWO&JZ{2 z>KWB#>Q$0Xg zV2aEGIXg(m1!3QLt7>Z@@4kE*b9rH+boyij)WxO7PphVFcXuwlxf3{%@9)n9?aeHc z4*L}k+(1fT9)-VSZ7mebL$KWebWt|X$+q?N^8LX%!$nG!h}*eD0&!WVeRnGgH@S#qNq%;4WFbgE$sx*J&+swE zoRrswtkR?tUR};59jVnyy0Oj5*kp9>j+>KLnhZ67GT64ef-eE+3I@{Z@$;{0ScZ7p zX}V11tEvGaOKO{~OeeSlOyemZB=1QXp*}ae${jU17INCO7*Yw)lXR1GLu3U6HgoQ_ z9lz?HaQ;ji`w=X~9b`eT@21mD3atZ}tf8I_Ij5WPPFy=CXR@%?Y1kQUuD z9;OAZOXk7H?jCOMhdeDC>AdBQ&kY#8iV_EVfurEKtQ(i&N(00vvqN{yj^=2?<2YwL zJI1YoeC#FVD z_k4$LGjI_=YJs2UAen*T&8-cbKAh)=Y6lc6c(Ma-J!_>Rr>#jD8HBSX za4Z^N@H*bISxx)jGEl}=lTGjnGVlXjM3(3KQ%!S`EGILeLPF=CE;qCvAtYgwoEl9# z!&>leu9U*H61%)=kTjPfkw#-S;jMt+bw1Q}tZEcUQ3|E>7G-#}nSr}&nftt zY~px?ZL@$K4-50}RT{qh&L4HtYa~tXEwk?(T;WW;VijP~wZ8(sdx0 z1i@n=Yx6=8kUp97tYfY2b42kdZl0oNP^0Sv_9l_Itwe%&wBnP&D~t>d=8|DQkN`yi zxvb*)ddYHtb^W~59=PmR-8m)3E6FuI(o%Cx`xu2)h)Y#-=VZzcH7XQJTHn|Cx*UDT#-Dl{H+B*N_1{&G&I0Q|v9e8yAo z5E{%R(iIN$2e^~L`Bqj+2NMH3AL#T-!C7wc`0mj+1LW7vb1FVFa6Ld_f8 zv&frI;B>RT)axBh&}R@m1tTEk`;rqt(b#a}Qh1#o&YVn-iLdj(D1veT^>_eZ5)ax* za*J=wQanPj7Jyq8dwxpz+O@&4b|PNIp{O-@Z`GuokmTNeC;A~x#ehti9{MVD9RuU zr7R{weCMO3R+BBg>GJ~DS($&Dr}C*b*1MXsgUy@)|$XX zwOioSL-%H!B-yC(0hPWq?<$}<@YCe%&G=V<5XM2_*V|J7q72w!vRk4eY_FS3P&JLp z-EjdcvG0NVk&8cqh}lIvN{bPhPlPBsCMU7+Xh7ZMabN(c9 zL&1*G6Q*7a{EwD{SH{GAP1)_BndC;@y_V$^ry;(XgO4)a+-EDSn+<5&ex3a>#N=-G z5E_KlING3NF+%;7wc90DbnCei{)ClewTJ^te4%=Vq!RH}*xs$^$t&^l4(30~D(b}F z=R8t3*ST>-8GvA??Jt%_KxPuX7UaKh{Um)(J5shpB!lm6Qf2Fs2M=#dMl)Rgkb#=J@>zIDW}wWs0^E z?SLw*A7dXT_|M{FKm!2vo{Z4H_x;Y6ra zXa`imv*T7^ok{Fb9^bh~lLy0e1hi#8=Z>6JP9x|?BMqZ$&cJWh#afzN zu-W`o(`CN#nI!RilkRTA_K<=!Q3@`ntjwQA+c7!B*pxIGby`VMpt7BTBo+kwvkx?K}9z}f69UPbGIHX z%OVb7TU&MIl<{>YIyEj~PC|{^4)NvTJ_*L8uNtt**$VD(eeaY~JL@98l10<7Uq0vW zRT1M@M#u7X{99T9{g^-^*jzZoCjg0q5g#*l2lFXT4H-Ux*Yz5Fd?RuBx8Jo+9sEDX@i-1lF}+iA8Is_F2>(t+K;#nc2?XU=*+WgG35$$$W&|vrIl~Szcy@T&6~8UvTE~rh4XOzeIvx8);$7Pi zRAG&(N~BHbjPIy-AN7cHhcb|#Hi_J^`2Af(K&4>1r+j(Ks=1)Arp|zTB~QV2Cs$he zvx)f0@}1o<*POy53q!U<@#b5QqS?)NP?(0)Z22n{hxncKsmYK}W;A;_kR3>kBlt*=561@t1P3* z@@@9D*&)S3U{=9ml7;ZU|laBEIb`G1?GZAiEIUYO!Va$ZX@jf22Rr+jt@XjP)qJ7kkz6*wWX@e34To_G}( zrd`p#OR4Z<3U0nPMTL*ZFvfPz0K0-k+Es~NS9ID;YO+f&gkj-YA&0_Yxh(CQapeLs zoEqu-!VRm>T;fUiC|`)(0WJ2yl*cfoFuMC?4u32subNg{z~i2g!f2&NjfTSoTf}pZ zgY2_VNKCR#oqlWZf2>vt3RNw6OM6Duo`JXOV!ASm_Ks!~Uh##Uos|Fk%t*}gcphGA zwjjFZtDJpjqe{b3$YV$IsaVJVb;gz|(hcY~RzspS!C^=h$QJ2FvC`s(>=BY;%^iue zLsTEkEc3Wh!f&SZ@vpI5c~#z0db` zEG7kw^$#x}o;6+W7ArE)s%p_p$-00zN*sJ`7K;cq$WW|&ZtVLy(9rw6oz?Dsuc6tr zlODxg6>yO2-yE=LmvmkwwJYa9nWU$5Wp+0EiQ~t>A-6@9|Io_mqVbl{!w{%6tFZ{9 zA&^gP6{a!(hoZ4&TMWQ^0b6Uux5vGqUZ7M?Ifg61)PpCvbs;2ck#y*2&B7i zbWkTrNJ{qnWOi|}V}5KrVd>wEf-d!EO}?`22sk6Ip)-I$H?TKoLnM1nD-mHcp@C^g zCJKswiG?CGW&=g%osZuiQqEoZlwSIv*h^~Ba_(`%YVnyKf-wa%y>61#D@_dHf-NZP z`*nSBe!2c0did2nByFKS2GBi5IsGwXCj749G4^ifIp}W2bWAM$FfVeIyZ9pFw9elF z#cN59>t~SAKIE!Xyd=fjKa~G1C1%X|x&)agb@Gb?gckG?V76LR<8TT~rzselZaYNw zXC++*=h8-Xye$2aAP^?V5A@8W=V+m4N;K#$`%-dk z{lC{$fteE~AR=>2(?NMBoIFkudC$Dc*U`-5)ki{*YbzVcKTNl+XF6MjgGMiHs$gGc z|A?Jr&-Qy|vs&wqkMN8>^3k)+GgZBg8L|c9m6j$SWLYT&V=YZooDw796K-OgLCn?T z2|}L|*YSJv`w0>Q85OJ@*{@-&<}tQo@*Y)+*-A1vKvV5}mk1D~7dHPD69XOGkaAAI z2Dp9Y<%c@8cX#z?W@Z3;i+rxMH0!#U5gRB;?xw%L=GP2L<3Pp(7_JMxvUI4dkCa*~ z-wDA#XqJ9hC_8jqw=T*2SqcMyU#C?Z33B5tOM)81!06ri`03rRucXz)Y4?fJ~r^u}ih~r|<^^(m3Jo%@*10XK4vlO)s zSL>W(4aeGhNt5^eJum!6X`Ro&x0Ks4jLt6^&!aA}mCLGw;#AMd72(YD)kHelfpMS| zbYCk(TegKrV;$b*S=!a!j-#e0k66sDf2pr`4{Nh6jD~OH7tXAppMOJ7fg%ml2)<2y z9q&?yT#>WY?Hyv%^GfIckEiG0-v?~r)H#Ps1ZQ9rsil>+Nk4D_aoml4K~n_tzX|AW zRDghf{C_?1%W~ZM@n3X!5zj>qEY26UVy%aOwGekb-gJe(`QMLHOCbs+?v2q(EIluU{%CuY@T_CK0y)JLy(o39@E15MK-tE zXW^y2T0W5HCG{iTrGFO>1#87mKIea5P|*C3(lYQ!uZVvp7m>6!6C9Gbm_;J(DRi-_ zAJ>pRbIpmqEcfBFI@3BogcZ!y0@t+OG(+DoN-RfABBlg!4fPf1VYHu#xm80|{W@?_ zh$2(H`&8F*)lZz0QNPT3{?aKK?fA5Vy)K14g*_MUgugDm#in&}iH)r&-pf4bvCL_7 z|GRa$m`neX@qd1Eqy6STE&KZ??uSL(|Idz*zdMS&1Nb7k;B55xM9m`k$D^a8Aj=v9 ztbrtTPI(7FRGN{&rdF&;Lp69`yL@e>LR)ru+W?){t|8{QMa<78d*(nkA_0_W);w|Afk@TTP_hhX<}3>)w+n`$2=yhEt`~va=A+z z9`jT@5_O_g1WGI7m7SNb#zv7#qpP(}G{Yk=9fIJH=k2E`e6c7L)yl>|+0bXYUSvTH zWOVU*XET`lcD0=<^v^$Dh*kwf7XT|wa9i(+ngnN*BVAD3OcD|j-vEo9Q==?#k9J~p z6$+p=*Y)W}F!!@xD_2AC8&u~nzt>28qxHxo7c*p+S+7k%L_oQz;}Dn%gf^W(mt;QmBXKpx0k%i*UdI*;tlbalW23uJLTO&GYHJ~Ptl=U$8l=f5i zuL`V*DK!wklh#X0Xot;slFP~j5o*MH(J-cs;}o9GGxBnI_e9H7uH-q3^L2=1zU7{p zqjn|}t}Y5&PurL{Lq{j6h_^ApJ4Qz?i#aqpLHNK{)9`pjY1p4PLEO+1Ge)1@&n9<+ zGX5OgK^tCN*&pyLO(48z5KX*sK?poG$ys_W$HT86nLY0mRJE&Wq^Ju*!(vS6> zlsZMLffYL{@m!H+=hap1jMW}f`%C^%>A@l?;_x?*)mL5mk(X~@jg(x|JuypL9LBd;v^N|kfUlf#2v?LOvkO{}RW|t5xo9az(d^u%|NJbzl$)0SjsSFdMu8VruC!xAEzAD6wW^=ds(5uP z)!cx|*!egi@72KJbXl1~!J-8Xku(!whQN)?6*0?*R2inM{LvB_?N95-LP7DiqP=SH z!Y0Gc8P~9)U~B9V2ks*@6vuu9jaC96{&@X}|1ygnZcr+FGH)t-IuG;-A1d*r2|z9% z8HB@Gm8ain*Mk-*20^JyZiI(6PtsGco|(?sZ$v2wukD%uwu-bJVNq~tl_X-*aMaYF z6RJ*48o{NL&bdAY`{9Q%dlvos9uGu=4=F--Ml^3zZ@^A-(+Ca$YqtyxE0`1pR>kNI zHx$Z4qO3*`C!ox6z0hdNT*B7VAfm@k)S;Ys!(6Z0WrSdb%j+H08dlRS8`-C$D&@c0 z`g-MlKcmsbH*BAQxq(eg6M)L6Do4(k7KI!mnHlCt(lykywCspht@`zyt@ z`jm!Q)N~O#=7p+J7qUbK!5}Hl02hciG-4j*Dlvi%4;naq9d2hkNab=l>X+1EG{_%b zi~6yFTN&(Sp>h7qdh{Ckg*i^i(cn@{?gEULCtREo7hIeL-PEOaEeinP%@<@CjQzHQOc!^6S0y;>aUWN3DNs3{8< z^d-siFGJk@)GA;EM;GtF3w4q@uV!zeQ&%4;dTSQxR%%>hmq@&>gM-y{a`4xiZZHt{ zGTO#HDAdqoGnZ!HQ`azh+MrVC`3k;IFz7IP8h><`WK2LEAaoCqa+7ca*e&{Q=j!V1 zk1urA+RLWF#0cB&42!PN33BJznbV+WR{C)8@l^AAT0yV;MzHPC7QxEkcC+tkW}Y)r7pgyC-Blh7}Ru7-np z&0*)|K(F?gAW_w`!B&K7Nc}qDxM#q^!RLViVt62SmIibTv;3vvyz~7v{lKyf4I`V*u(NYBd$;CE9+Q2K(H%RTuNuQ~&r%i6pTMV1; zT0d_ZE(V|E*0KCnK^J4_`*UJ}&QBwaKPL631_e7cGt>C;UcByWr}HC4IMU>!M4+hw z6N*6MVG{Etl!`8qauldqHW-@In?{mqh~e%lpPa)j%$51=2@7=((t(Q@dV(jVa_TU~ zkMNY7{oE{T2*R!E5}J`mmo;vmRc5m)wmYW`QA_51+1i(g;MvfaVM9&uBw^=EjLQ6O zXj2&!`$?G56_8(Mh3X;4kC-xDXHz5XESK)YvS;y66lIGHII_iWbf?a>v}cK2s{#iS z1!Gpb6)C$42$Wf?J*muQ^kYBXHDVWmS7bK5Xr$FcAaw#UlQfYJgzyk2NXev}&M3{3 z6?;fI2!r19Y#d{qfl__QtodB5z((k8(`AYjZ<0Ot#@)YU5TjZD#MXKBlR=WBN43i* z=~8S+UQIW#UWh!jPJZ$=?GlJAoh;~?Mu}>+PYM00qB_{Xwx)xr7W+3%ow`d3$g41@ zjikuuYMyX8FsDnXYunt0EgttrzdqPDf$(K*-cy?#0scdm{hL)577S|B_XYB-a0z!c zZ_a4Gu#NKv-8W2YI`&t`tA~&C&#lzGEP8g(nf-CK8=ET^I>B(i14I^&A`L&>3l~7o z1RGeWsmf)@j>fm@I0u)MfkxmwYG*Tq58mGBWA+FQEC$}%Fr_SgD^*Aa!RPU++{S+j zq&z^Mfpi=2N@D^$D!w*(t^YM6?f-;`uZ~>Vq3cPM)M-Vw>VS|-Bfg)>q^f+llzD16 z==*3Zp}$rRKO9NflUm$4!kJ0y3S-$T+`&1m?nIaPEhBI^POb}|KT;b)*pBf7u|whY(Nj7s zR>h^ZrVW<^{7Q(SL%OXdIR*lD%R6+M3dc^nphmU?1)Gj0va&r=$}{X`K$s7rCw}{M z^QO)F<#MxLpO4}+$`%=kf87Ra zgL_1Y7MIbTvCK5|)%hW#j5OD}3?L3n-cT@O#HO0jjeydm;5u~(7 z+MQ%W2bUm9el8p^#rdA98HVR-?X|V2`71SORkw`g`*s3%QL4+d-g-1Tixv_LX|%=! zK%=q)D>SlP_`gGal%uwZyGv~7avzDs>(LyUYnzIcY6U2@q*c-p{!hK8xx>puXdnm? zD{2H0z_8OreXC0188zn@hIy!A5Aarx0DnIMf3})uPQO zowPKxvwwK{W=M;dfvEz#iKJ9vUE}4V7$JxRrJbz3?+@v#Ik&?38z;iM4zUoqI>+D) zyuY?^_4eaV?&o6?STHlPatr-Iwt-^UbDq(4b(`BI&y+*CJ`mxCQzB;3$qnmY;eFZH zr&*>Z!Psg#X(PsmS1Z5IjU}{aQFojU3;&KhdDKfMmzne;D1Wf=W#LMJmXXiV>=p1jkD^iBQGWKoi^r4tD@k8 zv^CMpa&d*n-f z@&yyKl5`m`#r{4ilIt2PtqQzIzf`{GEX*vTLl|%y*9lTMdfnN-t{<+7JM;_5dxdcbUARsiT~<#FX9REe(#Q*5jF`GXP-v(!vkAA0A{&v`*WCxCMq41SKq0-W&1{~p z@>$>BB4u`Rw)kwb0aVw_em|r_pZ!Hkc-M!k2RaRRJTXKUud3 z$$u{`370DpLo)=fUAOh{^>+vEo^t*(B3Q1j`Xi~K1pohV_nu)*ZCl$g7R1h0WK#tz z2uP7G-GWF*s&o~QPUuJp5eqh&^p>EMP!oC!B`VT;N$5>_LX%EHl5fU+&U4Q5et+Lz zFBkiAUl5YD)*N$`dyIMCYhOSOgLuLS4kk1OkCReXNIMcfW|gIuubF;ch%^hK!Mcw1 zWMev1tQ!gcvjIM+zM2`g?TCUss$3z z0SB7%SHa=-wqq`xw0qP*g(D1SCqTV1zOm=~=RfMQ4r;s?4Sco#4AUaWbMh|!W#XuC zrN7LWyNG{BBu(jImWL2S-t49LMP+s`K0^lwJFOKshj3tFWA%!@w!b0B;oe_{x*ssu zr*(51ARqdc6iI$2Z#7Axm|q3jSh(ubysc36l;7ttGb~qq7Wmbif<6m9^5K7$0|JAj zIZ96~rt{)J>@6?Xkb6t9ph7j|DSzc!QrPVyKU$t$ZP5Gv>xp}>0Cyd|{Da?WBH{Ot zq_c{&h;5Wk;D+ij?!(xj(>wPK#k)~Yj>Va3ZdCf%rW&39v9+a`%gzSZ&n>i+G^9~K zoWpF!T&2S>8>{xZLx&!{dzKbLecl*vY=7F8KQOBaM01vPMX>#+<8B%4@vX8vx<~Xy z(gB{Hwkl-AO&lnD;`pzQOeW(oaMuuqd+Nz@C5AGo%J(a+ohYu_;bV)B~@X9QPIorKOgV7mBiLZ9ohe`!1KjLS-p;cg*U znnS3*C)aWzsfHCbn8s|``S+_{@jLg=eF!-2068(urc6<5>k!lYI(KV z&<>p8?1D?Lv6f&sx%l-`w$1pPQ$E{9CJTLRG^10&3f2h`pUbvyQD&FqV5b-u?n+f; zZ4;13Q9{< zhxdslto7UE@35VIUgDakyVM&;-fH;>Wqd95vfgu}C{yFvIgkhPaw~r`JwI7*r{?2t z*#7|Vlhv7nJnuCNlWZlg|1^9|MmHnW78Wa4bd#_TD^TECoK4#!bkopNwKyH?1;WZq5+x&#mD~Tgzh1cPAmKOT_xX1Y&u8xa( zs^Xl91^eGl*wK@9xu;Jo$macKbtt3!35bzF;PeMK2DL&1wb-1JpPZUi5FdZSap%TQ z03m7Oz2D;t;np79XV%T@85+62BR!4k%F}#G+JIy?taPrpVip<|1MCbw?MV8yiWT5g zL=pPA&Rw~`P1abT`*Mrz^$gtpOq&SXLSw7*mBx*w2HMg+0z6WNBf68Hz(Rih-ZJ&# z0x$X#ZT^{&MxJ$zae>ECh8yvY^YzI_oRJRJ&SqfeCOzNNI7au!%H{zt3MFmwSV%oT z4}lD5i#nmBSpFQF9b|2ufDuQ66rVEL1`VNf$_NY_#DvD>A?DMjEAm;SBTJK5k|Qp&T*#=kN^(rjRJr}jd%#8ZWKUBc{k7XQbC>x znNtEOq>|!BHq?o@So88e1En35{OO-9irxhV*|6hRUh-`{3-S_+h$kK}>D=h=h-qWJ zbs88%;DmW6xBy7@>+)))Ec~);DMkS%rV9)V6c3yL6V6XLyNGVA@orAH4A>NP6}NTJ z(!L97GAHXD{Ct)NQM!}q-+S_E{t(!NoMr{sn3Rz|((w7Ugrjc$K{fQ?gbJ9%u!6y% zm&YCCBEmUQtw>yw?OTJD-i}_-wtZ~PK9rnIt}sX4i2hX=Q7B_phduYJBtp0 zIk{*O_qVI#j9ttL@&n0jIoI440quTX+r$+lFeYHzy{g%U0fgPG)_aXO! zyo-g%up2l~(E%$&P`nJL?sa!=3vh&jdirkp@NUGjRH%M6GO|R;1?n7_5?_~K-q~3J zwa(TukbA0UQQEu^-;*YVwira1r0g2&`Gms>4^ud!BqbGVJPn7)!_zNxlf`6nUr_x^ zc;vasr@4isS9Nl;8t647$RbzV2z>h=yp}n+*%K@G_Bmx&R;7ybMX)RcXwZNKfp!IRt4y(%^ zZHVhAN~6qH9X~!-DIooMhpa09+3`>{czc3}tB3 zt)$Ho8lUe$CXbMI#DyJY)p=8;?|TFWCdmoc6Ro#4uaDv+pOidLl~U&i(Cg|p#A&WX zHK1lo3ocXfW4LY{qJWx%sXMk4>;kgu4eVYE8p(kXW80Bdxu?qla;47;saq3DMhKrQ zboHzP+@GM%sU#+ltKJ^G#Y$=ZG>S8nP%TAj_bi6=Rh%Ji%?oLk1$Hn=L32t?79}I zT1(8$9fX=|vA43eN{ zQPy7&TKblmu(v>=pXcyXPc0!A%Qaa-V{;VF_0nrO;(a35PrKo) zQu+SA{1mfPYUWt6dS7mX=DkV?&26V5vwVYsPw%2dP(qH+rvm`S>6e}}ZDJg*e^p3) z`naqznPzWe{y-EPcxhdZ8{7}*eO!NbZpC#4V1HG4?KX@w+AyZ_9O8 zR7BO3@=mN;5r35 zi{aR^{S>(anl2uA%dx4qk0Zd*>91IF)Kyd)$*0uX?9j7^vpRXe zd#VU#3`$pW4@_#*B-eZ-$s>n|LB%#P4}Y31CjBfsIAjxTT?R@J*$*6|(?#w+`8kI6q=Ex!>c`-qbxK;BG zmd&RgW8WRi+QsLC-j7>_W~p;}RnJd)%jy8=V>va(|1!B8l{txW?ZY3-U%Nj*dKo}L`}!d3goIH+SR;@$+qRXt zwfSVK;3y~TC8Cy7V&}_11LJ1M+t_ZMM ziSShER) zpxnTSqab*Z0N2Ywcu3ZxIQt`~ZO+}hCxrTTRUUZ6e93dYpzlZVY_vj+4x>Q}L3?B4 z%~v|uxbEvUF`ANzDKK{`03M{wu?|Q!p+w|OyNT>&>;fXwSST2BmKwq4m zV*iQnEe-J&q-1%e0kJj=VyHAS}sbl~e1T?U-1Vr9K)=L!eC zNqzH^)98c_#V(C?Rxkd&cTK?Yml^j!)|hEV^u>_P`T}Z1*l$#Wgcg)_s;7y6KWk0& z(1QtW-?z3}AToP9HX0K@u|I0iOJ}Z^kk7g4#OTb0pH!w|$;aYD3%~dm!Q?3(&{E(y z@*>K7tTN6y0w8k79sn6yAhYA_SL&lQ^N0?9k3y{<()+H$!$axuvAKkj#84wM zn;B48Z)1ga1#x!Zn~*C--d#G|E?#eK|KjSKOW}y3CPxE z4$4$I*_Uny$G_+7vYn-&%SLz~+8Lhz1lAI?7Yy=geR*moD_#`_VL^XDxU(5ZWK$|2 zgu5mXw?Pd7N*1V%4bM)vyvc{3-*u~4^Qp|6KBI-7nB_UBxua6FtHv)s)wtFN$@sx- z+LFnr(Gr{%zQDlAb3c5|d3(gwsq>*-zfvK@QvP1tIa47n%G7hkJsp$?b$$io{U*O~ zS$JyI-?d7Ff)BD(xap^4ALQ{>Nf~Ee>D8!+q1KmwOoQbIdp15+Pua=`w634v{agFI z*4Z5Oqg8(BDwDy<$`K>ws=AlUzZrvJT(B(STEHW?0UG~`>$#l?B05tChceHHqslq` znr;+F-Fo>994E>_Hf<{fydM@4AC8nr3In2({~j(mpW4B6HV#A>=yI8$dY=SDD)d+k z!;J}$>n+v(o^x6e+g@ygnl)FdfywfzjYj4(cmNVqNP914i>~};+im$@BNk=4T$VyQ z^cnP*EAzuAuE(f@K_JzPM;xKk1p~J7sX~&j6SbzF*hG~uhvVPD2)@D=ReGn`LC?!c;We0DX{61y=vUoIJ_(IO6XT)MVT~C;wu=db+6TEI!);eyOS(Y{G(N#oaqU| zORMDESi~QloCp)eGi={`=a;!sFBMr_dYtVmUDj%Le4x;ZIDSZPxGcb2l={*Q2%MgI zS5mU>YHYl18TH7vO5|YV@OVJu>2N2r{7K3N4|IVjLmY9s<`{Cu~WY zAw8#Wxt$7kE`~3UL2D;j38Q5nCeV+yQn08DvduxKNr0J>UxY<*0PK#WX6=C`az9A& zcvh{$W1NSTE7zy(6i;Ke)4u}XK``#;ReE+9x5ie_$U`5L<0gXAuWP`{{q6kEEE3?= zFDV*5XJS$^_|7V@4lhUPQ6_Io4{dXBh(K z<6q9SM29nNEvIqez|2y~o38)`>*H7&9~V$8dB$5w2Hh+M_c3|C!^s=tAJ1OC60=?0 ztrK5n#Z2|A_MF`0K=xj}CM7(WcMu;W zGaj$`QI|8@4EB`S)qLX%Ptc+|s{D0%v>m6CV}uBHo6pihUEj&>nnd!=#HC&%vUmH; z=r-Km&uIn`#r&MUUB2+r@1RE_u2f{OQ7RgLprPwl=H)E4*${IbNA#mnEuVVx3Y2BF8EUl_UmmcY;CwyK5uapJnq(ujaJ4+zm}r8vAf8N*_I^^%b1>(m z-r%$JZiUq7?C83wP)Nw@FS(BGFGN_bjq4yKK<8HLcV-Q^hW~s~P8`y1&S<$|8Ps^X z5tqltYgIsU@ts@t$4oDaI}&4(X;~@dQ}s;SQ}>t&h&7ACeP)!2jZs`)P+=lHcL=YX z_)6vt&|{vcRFQ$r@d!iW+PbK)X9oYCB}@ht##d_CC@bo+ zo%Prq3x6~tX{_I0s*Jz5tC$mruoP9#JLth-g2D`GeXquRc`#~|`+XP9#;zGzt>5bW z-&3MrCa-*1l4VT`$Q8MG*|FIsz~yZnw?Pup!YkViyK`3??b=UbZ7*|a6|d`;)heaB z)w^Mze{2h)bX4v5TJDDEpkS^^@u*(yr9y^|1oL%PM^X%Ppq&W+%Glq$H$SzfREOnAy^Yk zgpvc|)}#*%DxIlnPL$!rP>Ou=??yn+Xb2Nv;FkZtJ)WyJcv4$i2#jCBd<$18!*z(M zOvLtB`CE$kmt5bW8!-H338%*>b%SU{_ zrA?|G3+szjFaJp11Vi{8LbM@DK`rpbv=b+j?JZJK8=Mo3P z){NHX4HP#ykmac5FYRfbD_)WRW0o&xF49+TYH`7ubR$K}KwSH3f_SJn$GxO7N@1+J z=JTegr1*|7a<$Q9a6#Fwb$GJBZrE>~EcT4Fyf}2oW=Fs~Sfb2_ENeXrkL~cV#2y-rX zrn~+`%;+Au<2oelb4}#>tlWfKbdokT$nPXiWPaVne%poHSfN)b;53MGKTB=?a$^?- z^7}A=p)OtQ@c(C(zx{qk-nZ|}B0ZVb;laCFum_^UWXIMT?LpBFgqGy&_?g4>&*ZXC zoWevTC56R^FNKLGS$HPw8}P++;WdR~e6zyhv~=4lBmxstSN6&6S>JY!a-lzn^>q}GAz3v#O)u-F>D!fQaP0^9OI_$lF9vOGapA~BKYCdAee+UH_g|u{T zK<mfqkv$-zv`zbUyhn8FS9lxU#dekVeiRU}+YKypb z-($g0T992yCBH9I;IPnZOmOu-HzLdT=QsssAt_MYZX`yt?iXZ`ax?)(?LkOxb$qQp zH74O4!Xaa=7sWCb>ufVu(`Mn?U=W3UP(KSfcbZ==c6sw+Fcq~@vthwJPM#lorgv28 z5CchmP}uX{gUe4%9vFOQze?`25C2$D?`ghXdB&Vye6Xv-O4*A%H=;)K$_}rI9Q*y4 z)@u3vNNefZNmCo~(Jq5*`&@Y{sWH3ArsQmkzgywd?Bu_=5+da22Si>TEDvAq9{4OH zDpPL!UjAk?Z~6@LZvhd1lfO=$I$Cn%^MSDCXlJ~FK(%F?ONSip98W1rW2%_TTXd0zeT#2s-CB*oVpR}uYn5K@yAqqNln2-HQT7RN z2C@qmKWwnMeOzdXSe|v|_JKz}+VWQ(vQ0cv6&UQAmrUdLmUfuKj9pUol%% zM#H<}MroKB{?g-&qfDgV<2-!r6D!~RMPe1kN`0m!e5v!(mZp{2Cob6XyRJaNj(%7Q z(SAOTzDXYPgO`UML&hyXWFIW=%Uk&P<@2sMXK?2!Y!|8U$EumH^3#OJw9Oz2uRisn z=#|@tRO_2ncV~{0w5x43`j-!UcaH8^FSRJP##q8y z5nP_eHlCe_lZS_|+2=JYF3hGmZn*0XZyTK?D!1rOtMFBS+&K2v_DerA;YbB&sB0IN z3qKQOidn5B%s^Q1c&rbb{g22KDxl+NXLclE*#?&T37S&k;uBcTOS6;uwrajHLF)7q z@Xh`g#F^ClPLmf6g=K&K zuK$-Ob$2*NKPpuF5&rdeC!;@KuS-blrQMs4zO1hcKHJ>6mDGrcxpz%=WnQ~lZg`WB z`fPXRnQjf%v*`2gU@8{WRcfysH8=v_UR3Akj}82KaaT&x#;mn|g8bU}Bp?5JEAL;p z{@Jsy2ue5d_GVA&wWZvR5DGl~z2ld@yE5Rs4~KRPQ#NQ-JHr#-RS*<%cfZJ>bjpu6 zu{FE>&?sb5AqZ{suPsKp_f?5-wrcWnX6^o|136+6(2s-+neV(A-*P|Omeg`GHS+a< zcG_iARk2ueX(93u{O+D#hMr(YQCD0F4Fu13Q7pC3h6bSqKm_(McU_Q2|f)* z-3Fs|ABtw(txYK=rjsj{6m@F-*a4LF$!y^Qmdcj$(WuDnd)F&Qq=gOs@UjYjD{Jg5 zPZj(3nbzwN=sizW`F~>Tm%=}f@+P7>aqND$Ow#aZvT17d9TUy`OR}xcE6X9D{$8$m zZ!0Q0o9uj=&-qy&JlnFwcf8QJK*h#6 zuZP0T@Ur1@OtDGV?_)zFcLb~^?r4b=wFW|O#+ICc7l%%BS-psF8Y6 z5NX{lT{|j(;euJ32(}pY4hMa`fQTczVuFixiuBjn;5KG=9_sX@T%4F~6?z4kUIau+;hj%cKlyUhM(_L!=l?QC*z5ARlGIGLJx-$Uu9c6 z0e(@~hOP#qzVE96c@I{^!8S(wWUGXBwkFL(?qGjEx)8HHMC-qInXEIgao^&Mm~ z4z|nWHUW9|&m2`&!-E-PQnKbQm_nn~e7mS!LXxI$n!iZI<=PeUy^XLFuNtdkciA!1 zg*@>mB~Qe^`5oCA8|YG5%v9o=v^f;a6JKj%My^)ek+KUP|K~1x{v3&$sP?{hGKj>BV9e;MgBp@k{5 zos~r2G{y1I8u&ZdJIY1!=Eim1gmKE&t=;56T~oF+mC-lZ5gERTlj`8MXfUcME0Hi(?IB2}<9CdR*;!yIw?pBtb@ zPWRe2ndxV9Rr~{~oYYp-*jPe-gAIlcnm=o|gZ+Zm#nFma%n4YiTk=jd?Sj?G2W# z*1aaiIZnZs)!3q0r{o|Zwh4YFYf=1zCC9)2I4poI^5PDzuOYUZSml6|PUCG>CaId% zI|#4(Q?bs6knZRwZbA%gc`k3K=06LUI3KGbc`Jce{J-4H9WW%cOLRKH|eEM@~CD8yFZ!jN#Lc zzLupgp!?LvryM9UO93e(0D$X)HEn(k_||X(1AC>+9R1rXuKkj6t+uww$iAgAIXO9S z%3AWp`V-w0p4J_RYct|itInHkr&jI;soN~#Gc$STmd9%-tBIEtU+j|v(B=s|GB4i+ zW`#{vZ=o}$13X>;fq1^qS*_r)`gV9iCz7_vg+rf1h8{k1?5X{j`qXL&f|f7j^>UhnEs)Ry7@ zb-??ZM}x$|ERXkvWD5^r^Go+apI#@Y5b?^s!9xinE3|o1%k@F&vDVdZyJM}b`+S}8L3h$P?)=dPF&}wVELW9SmOuI2F*X7lnlj|?+ z{f5--4VdGp{su#tp0ebsS1X4YZXj4Wh;L?XkGNk!zc=-J{7J@vpKQZqyKvWEp%+vA z(iK)WCh~aHqn!B^vh{hj^9v#`VwU!M*+Q2QvwKW*|470ob!bEV$`@z;!{!@qohdWW zGgYI3-KjFlL)d(^L1%uyiGM5{g;YQe&oJHi`vi5|g-MEH2g}ADZ$9C6n~6c?P;;B` zLdWt!#HL04aiq&rx9FynHJDQtEG7m4nlw_1}`7k(ZXxe?NniijucBsLwjD zxZzD+K<;+wf5A)}deyxuWkqr8vP^0gBq{Or%hIApDfl zmQttr)MtM44NGOp_K%4CZgkGL&+Mdoe6M9q!&f%>r{ygbLIY#|kKUSW%qNF%6LZVW zuJ@$TRSSUPM^*h^qo@;Mfa)johu1_6i(OtD2-ztEqjsMiH8f>-dP-q#`X+pPUmL&( z7bula!lX%hu(~;JdaZH6{U)Pit5nvniHHW%&TmqePH?2KcWr4se5%WB>-AR0!T0KH z)$7}hC&1~F@bn)Rud!nVi7^Sf?iwB2X1{0s3dtzbkCXEL5lzq5isMFIdEa?lI?W)9 zd27+-Wg={nM3b8j<{5eWuJFOb*WcolZAagF{wU#on$UQ6JFubr%ual5$4?ig&0ToDTl&Oqs^u!3V@y;Odr-m?y!TW)RrV2`)H1p? z6RU9t$Z7WgenC`3#MwU@H;K1w4vZ84@P_c8=cofbdQNU`@z=uHZ{6jk?KMlej!1Uf z0;-5!x)x5#^G&e7=&03qhv(jG(qd3#0bN~lLtF8V5+z&w| z71GSEU%yTz@BoxA-x$>gSP!!-fjrE+b#^~MhX`o%4e8P@zy+rF<^am03v~8pe9zZ= ziavt{rWt9}t!#vXIdG-*JLQFM5vLfM3wL*Y7qQl^^ywimq5F_m_NydBum7d(eVSOl zG5_hXK$5J5c<$MYkINr$Sl`svRgG3Zlc;3$*_IYovs8FUySJpIWWT(;JUY``ZY9?c zsAhmu;Ker0f}G(2Z6NLADJw4r?E4*Q(4nCaHD5ggIk>UEe>K*uGHjL4)Yhm)r})Lj z!I)_X3Hp5%o${f;?W6Ft1R`R+Z+%Q`^{QySkxsGws-BkEPG;dRA_?YS9sd=|j(*i3 z``0lB?(tNi(9LgttX=apq7vxkb@|W!Z!B<7pTpm^M234)lt<4;UyL@2{pPkC_j+;f zQZi4#ceaSDiC5Q72^bb#N{pEkC_;#B-GS`@0&T}}j8F0{n+H#RoM&)%83w{cbh=1$ z0HT790{BJfv*JXonH5+M8u)oW%DfWXBVo*|8}}{b2nEOO>gr1BrXA?k0ns8(#eg!U zz3 zra%VRG?v+jfINN`NAM!Ex`1-JV6!$t!PKTH%%krEKOzU9)^my?49&dqiA!LHsZ5}* zwA2zDWa2bt9b6>50VAU+1o_xos3%CQa`uCaV;8G0T`Ikr)npQ%Z!>zuj1#scF)QxJBB*0mdYbETSOLsMIb)jVsX%F+GVL{c@c%^+!(p~pH(G-@O$JdPo zdM;T%;^j3f9XNoLIl*_?FS^gXeOQ)DuCdn z*P(2bHQDOLG^651%T>&BN&8lBWbgn73z$b(WK!M>0sOSWm1F5Z;w`Vf-pX&>C!wva z&AVS)CO7>D1{Y;nnVETCX52dlBvOF`g>YH}FIA7O^3@DGPy;JhJvbvl#@zN^)T66K z25jR@aARZK7{Oi$jBKt5#N$aR*nKYO`fJ`;Tj;R^@+8elSK;>?IZUG>uzklpbklbIO&#A3{^I2|>1GAsH05}j;{ z>czbi(77I|<($8xKn?2)#}i<6tf;)6DcxYL&sVG7-@Q!nd5a8YYbsr$)#nB>YXW6E zAE(ji!0k@j=&UGcJtRJR4)Zh+T7{LiN>s-~X9azgeQ=BBs$p+WTYK_w_?|R8?#g9$ zXPi5c95j^ODH#|y>+h!hYF=|?)HIrFV+K zgAEZaVo(zzMyF9i6O~m@3;g;d?{S9Va04T`*dU&~lVr~|9K0;|G(=dZA`tdt&v&@y z>iBi*nOC{|uC?U3Nj3Sx9Ph-pr@KoUJ}!41J)8a)jo9Y)ta7(hkbT&9bx>>FXV&d+ zJX?eb&;-MPz3DyfGx%@mpzR;0M2{Nxtt~GPoD!}5Vh*V@qH{(23|eNd!D!^X{CqD$ zroO;9c-WYgiqW!QN&u8bo*b<8-=wSG{P};hWzE3waBeoxIFy-qkpYg-WphDl9|4zf z)`gkDec2rBdLZ7G6$v|-;gocU!d97s2pB-f&89QiYo|zeT5~%A??9(ITFOpy3;$dK zK3B?j`7z*0&Mz*06kP@!3w;{{w%Q67pz{YjEnQ5b%%=J5g4|F{@b#rQ6RTc?VxTv$ zMj}wt+9_Mg1zgrklR&3ydj$eB2e<7u@v_KJFe{u(CJziAv52{_hw3O(yy-MCK)0^V zRvdf>OocFQ&56QH=~T(r9A}!sod6j^^#X!;6b!T2*=6Y{{QhahI|Br_w&<#MyJKFF zAnu!)n*&1O><=vbgf}cY>jM{M+*0%M^5_arAV=Vb4Wcc9`qL5)&d9*6yg{Ff0mqUv zgm&o^FRfykn@!9;!fa<0e|^`I-5{B&^z+r#m*LX9ZP7@6||!bgd@R7c5}Ir^h`W|JieAIK=PM_qRyx`d!y?j1gVP4^URmFI581 zMVAIN($`N123EetMS-5BRV7 z(}MK-vvmiPn5?DTn33 z%*fvq{y7Rl);!r#=G4!-igO(_pWI9KYyOnCI|z6kA5r~To$q#$UiS;EYg^2Td$r1c zUr1R#A$_*{)Esk^2eYANB>b7sB_Y^fVTEb0ev6|qAyaUdxb(=Y+1Tz!)yx7e8+iHe z1a#`!{n>Wr+q+M3KQKWk6sOY--Im zbjv!4Sm@l9dKp+k?csd~_e+_BQHF zl2F^;-UfL*9(&M^U8HkogJe9z+^t&G$jHd7_}NI$QnaQ>Z`9>`2aSpq_T4;nn_cpB^;_WCD2<19TybMUtf42sgb|n}2ExBU zsTj#-_2PS}(=?z44!&Kjke!GKslkm zLCS9DW~ZD@S*;%E>pcm(@fv}eej#RDiALoc;(~m>u=jFv)&3F zmVFrd^v2(xZu80>Zazs2Na2#%LtCj@=6e>5=v>lbH;7Kk){1_*DpDv<+?$D;bg_xB&eCB~}_4YV>!%Bd- z9KB))Sp52KiyqK`77T`2 z9sC`>%(N3at%yJHELUJLeC``vR5P0(I$2l~9Pne(q~2TXx@J^t z_bzKwS$VZf$>Xacb2GxHRy7??R(L9I3^K2()#JMi40OzO?^(NAYjS7PRZ{@*EnS@p zh+-B2C4ua&E*;>Jb4yD4s@?b9yDV!@Ctai8cm@p7~1R;i=M<-YXn26$GnwBR%BVk-3aCGOWHfXGSrOn+S9Zp|c)?xIrMFzU1&2n`7+ z@Pw^uAJ_Qp&Q^#qM6e!}_$spcigb2A3<+7;6@mYMssB4wk0P*2m4k6c5V|kO(R0 zZ=YwYuRoU7lP)UxQd7T>WZ`p9c6ev+JL_2X+Q0saT006P1OxslcgTfeO>wxz11}j~|fz%{ZKRxEj#PD)85t&Qhi@^Jn2C|iK z`IiQPnz=Dnm3{l${jQ|8!zU=)xw6igTD>|WUb z)i29zIgr#f>7*{um*~gaQ`<3V2lodpyu#_ZFc4b>nisIG#94>eG(ITcmGHN>lsiaqrJvl>`U?a3{{q5%eLs2q;+0LT<~n=k^3}5M|eM zEw?L9l+dW-xNJR{+0L&X;F=rHOEqUW!axd{x)7l`aX6u9KN-<7I>E)slEWUIb;ZwA ze>M2#PCufod^@lA={&+?Gh4y4kx_8?V~&2UgCo`vP9!&8r+G&f=X?)!bc zA;y#K7Lm_xXQt;=mS}rkTBm#5T;7|q?hUJnjnfWiA3$n0;bOZ#RKI%-kIvS>3;m`u z-1PmFsif&<*2nPH+dURSx`8tGZ;@ltah3I0Ym+nG?N@81A|MU#mKxOD94kDN4+ z%Cx3qS9E-WKgY50BP$*IQUZD*gpWe#o}V9BfCJ6~N}I3Z4l$_G^GZbE>g{k|Wvjs5 zRa~A?ei_hNrHiVptN^(?mjO_bd<9|$Z_o#Wcs!Dx{(%8G5MX-XvH30wWnF*#thdSROaYGtq%IRb>P$d>fCB} z)luEhYHiOQkC6Bk=RSfO+hPR~N(szA^6gQpywO6M`=(CL8=C)QbAUwTjNLC(*~Zt}v*v<+w&>PQ)bhp6Y@So6&TmArl|%-XU8F_q8x-w) zoj!}H;CZulC(-3zKUG(xtZS*kwETQ?1_%Z~7Z#r+8Sx;4^VYLB=&D~Qp<5ZexGuPS zx$CI3fc&A0kiqts7F+SJEL{u?Z-zmF1#pb5olywUUYVTar55v!C*UC{=YhaY z0+ooRY67hl2yQqjX{vzJLMkf$*L@GKoq2U(jEZg7yCA`g%m zdIX>=GnLwRM8Ms`qU_-oT6{@MG{WKak;=WS;{B& z7izR@*}ifqLizD)=3ET}B`-L-=2@uV^<2+TbnT9urQZr`@~>)bNL{dB_hI3Qk-U;S zad|gCnN}Cx>~Zy@XtY$*`U2HvsA z4n&qIe}dCW;aX4lu87~DHxB#APoczOR505CPyhRJ<6Tw5?>BQ`2 z1>g_}#R*{d)a{F;?_T>%<3Pcu6`G(ZbHZTEHY_McAHA?M`!!M`H)kv?+&=x*EdFZa zH9y$C{?6(p^enk`g7kB;OMTy5PI>qvn(}+R?PuB#-;POL74+En@)v`awe*}>g%0>3 zYY%s-c)LI)BjG5(A6pu{=88oW(MGV-Caep|3(j-czvTw+E9)wMKUp1ovLJYVYZ`mT z+_yO*pz*BWS>eFr=i?76Bvtc&-BzrDpjKJUM8g)=Q97P0p9qJVs{q!~%oC`E1>G8j z$^;2*17L1|{Fx>B>Vq&nkRsBpV646xRNHhzg|mWQ?_!rKo8|0@yCwZ_zlmIsK+yXh zbS>TOxWXV+&|g40=9QP@PQ2%kcpe6PBSMAz_7vNnBKyi`x;~Q6xL3C#r^IuuFLkcl z1RM!uOV)JI52)~}I`n+L7S5w+22!1V&TTbZo33PRJxLJw1JNN`RsH*sCP>8~5AW+U7wB(HK4zYyLmS z*6m01~x;n${a&ng==)5m$zq|tw2>1SV#;4$XG^^Ie78$U+n^t>X_wVv@!;WG|?}#&2*4A9>kBlGCv=JFR7%l9z*i{my#8micT(u9f=znLnO=O!yrPX?>*x65h zk%t^qb)}LHL7y~ncU~+t*EBBZPa(e}WY|X-4qziE>vh-=aVvpN4+@3-P2_it7dtlj zTl|t4xYd9MQ<&VkJvIg#LjE~Q4}dvxK5KJFSJo_ruS%-exFa^E|1Bl!;0j>2c*LSM z-X%8YE2oz=T5;KeX<2#a?)V$LPq2)Rj;L=Ae*s_v_Uvp|FR^AGK5gCXUunZzXmA+< z`i~o)O#H(p=V@5M7pk4sR{{t0zI^mq64Co!_tuTT8eM5OaoES?#Xu`XsiB#>ab2>N zt95?V_|f*p`ccd<<#DS(E8FRJHda?Y>j5}0Uc@pF)cUzVoU;a~hJ)s)&R9f8EF zDQ%oGc0c-FrqgxwUJf*q04lDq=wp zMUk%b4q`z>iu4W&Ql(2T!GgL}M5zMOlp-}EO}dIm?^J^OrO>)ze*!cNj*3`6fzek-q>{pQp<%XZ%l7(ndys{Jp`DP~ zNAzMXuqqtBmt?)?x=+WLzJ)U4BxC)Xl#;sGe1Ek&*Pv}{34n)jyp=dqX%Dhp@468kG=!ZJi*+sLbX#& zJt>aLY1LTjanPVLTBsyCm-%SkGersY+j9nW!S-d#*vD%9sXdNM*x7?(rZM!I+M{z6 zhI@Ek1xaXVW!}IsPWH+P?QHw5M`}df>?KF1P80rKwXB|63?}6fl{}5DAge@6*e~dK zhNn+AwyD1=r+&P4v}eyQC`g#j#xS%!_lZux@Q_ObLtdkM``%F=N+%# zzE$G_$G4B4hw9c)z*5UmY4=pL6H#Lu^*E;9K0`eY-q`Ec`t%M}Ek- zWDq?cgys$8{0<#&FCUfj`B1e!A?&ZemuO!N8r(gQJ>;Oq!wSviHvtYu(22?}KCwjehD=g{t4@G*sY2F!RMLL&#l*3+ukschE0 zo0g-h;ZWj)A+jr)jAhNImrkNuH@XErjxbUZeLCsxXb79}m@iXZj+ON5ohpfF2u1&^ zj?Di>?fN$Ab0fEhwlw$3^FThTA`rxDEz&%5Io2oTjVHf#cRRGImrOUR${*Ka&Ch)| z)XU?!S(GGV}!K`w7+NG%|7&dj93 zxv^)vS16g`h59V5sPUA%w}Q>t!^UKn?l=bgDQ{g;6; zDeTF;USX4*GpbQ5*~`E+odnr6N87a2R<@O&jT~=2an*>MhL>R!iC5s2xju8I_RSYc zh#Rv7%|Yy*8MXkBiU!K4 zQ>qp!uU{pcbJu5;@@W+_`;oa*=eGXZ{B9)S!O~xcPA}nIOzA-@U7vbmr)!ZyltD+=(7(6>c>^9 zCP#VkVb$h7jkDLU60fjiKdygax{LYNUWPf_B778bbDJv`^G7Ej8pAtdzFNoZ9>n)R+iRO z>-WFbU&Maj%2fB^mW`XTZGgMxdW5pR>IW-K!Yv&Iuh{A!AxcJ_-wD}MY!Y7xu`>nP zgg76bQ=uc~+hp8uOXJ((8>0fRKAh1$v7+Usp3|D}Pxc_;r)b%-iLZM4Fjn+=@(fT4Xbu(cN7<$&N9_lzHY#jVaE+i%e=y0VfLBNosv*0W}QFTsA^!@lsldU&e$GJryjp7f9Q z63ra-?Tg6FPnm)R939d=%$+g5e?YpDFkd%hF&Z?4e^lZ;KdM=~+hsH{pwS?s?Y88B z*AI59an;SJdL74Gn_A>kmy(WUF`UIdctz*ud_)?MRL3) z{|rhJAMn;&oU(Aj#72EB={-gNPPfKQU!>0s(FRy%dF3T*>b>8UD-2&?`g~qkKx*JG z(`4oQ*HV9CW&0dT`}2ZK2Ns-r3$}#&`|OYYShru{WPOD4$FJdm3Kav*d0+3oRP4R- zHDu;maCJqw?(1xuk*($7*oA-KR;qG)U4qg4Br3Y9-x%0o$W<pE_9JI^S%D>=nj2Xgg!;+%aLbq309oMSDDlyN?}u zzfHKCSKsV3nSTc~^jq)#GjB3Vw~UxuDGU<+WE=fe+vQ+iuSHAsbU^0IpTuo#*_dOs zsfmjv!ZO=LOpnWG4$d@`kGO4SYB#x;{nY51%D#YmL7MQ#;~vTwu>r)FykMd)TrOHv-hj_8+I z=SzTEnr-wW>h7WK>bU0o@T1WV6n*b6dxk!Y=$rXIDzxtt5i}`KPq_U~jMP8iNmALh zYM5$l=Cd?g=Dd0~DveYWRO_DWlGTv|6n#y)?;~#ef#QTxBI5l!dQX?_K9em1s^~ES zT^~a`3;CUUa+7xTCDpr{{Vw{W3c%XdYw^)^o+o4ar!YPZGaF&W6#PzSRrY7!Kh=XX zN&frW<^O(d;XP4l=^|HhATR^p%1Pn*~xMj9Zhpjm=86KbVi?*=$?Wu}h+_%Ikew-R0cXe1_q{|6AS=Z@^>m z5i=M@9j{Y3gA1dsPbjjc$n`wCLm0m9DH6DE_U8W-r=Oj168xyj1RT(IR7 z>9sbRb2}69%03R7RnX$n;*07W`SL*C>FFp@Legrip;_a-&!pZU{v5erU=r)r{&cQP ztM=uQ)R#H*hVplh9ZPm6w5-nrpCMJ(uDG4!=K_FK`I8@->I6mK@$%oFzFQ`)=g7t0 zn^C%=K;fSh-Z{$I>e=xlw7#l;?K3|BPOYlDqlvN&A&uDBH9__$|9(Q|jVuoh1LQq;+VhwD5~xrq8F+Z`Uh z@Q$k67xSwkVfs;x}FOU=e23>H7VK+ ziq82*+O3z=zi#`^rGMr3U{tKP@~@YRt(!}e?$&s3{j%lbr*eUG4FyX7>b;AsKXjXK zPTruO zw|j4)^|G|`Q-K3tPdOLF1Q`_^?Fsaw?cHs?g-5bu3CHQ5BR?+L>~HQ)+8dvCY`($_ zXLinLVj$YFFZ{M~u}wzS8MF7<)vD(XFdck1aQ;fN+{@8(_jN=bzbMK#T~wIfWTA2; zF|y=E!Yn>Tj`~x0E}OgIFN)i)-R`)Bl2xaA4~??F^=q}f_O~gXH=3yeGb#DBjQEMK z4|nX8eW32Z!#KkxgZjXWkyX8DI%mDs15lCbOe_mEU3Oo%dX zY9_hV)@2A%Rdk$HRy0Xb)lOH<#!#$=s;%RhY0Bzup>Z_dz-f;z-M%(u&~TyE(Gx&C*E1q&+09Zcv$_pw^8{Ge}vxh{#;Yu313bN z5!YzAg0+@@_h#nAcg4J&f3-wroCxSqtNvNHMLT7dLnF_mZ=(Dg`58?>xr*k^`}SHJ zKt#D?eT#2cf0o*~z|S|kVRRM|gTVF6r|O)QA{M`-{#Gwb-4bo>GN{gJ~s!nl64qoOtHk5+7Pe=6SlB2RvP*E zb!$%DH63?25HRw0qW{amghQel4cTma{X4#mowHbb*8e6$d-PDLHbXdtZ4W_l<9e8A zS1^0+Zke3~MD3~4{72K*yl&3vb0<zU^_ zn5UuAb4A6VMXt&oyxh~9E_LF&t@vb1tr*8%r?{yPY;C+BAvxpxW2K z%?Qhoi^0x#RWy`SYYUG34*NE&uhkLHXUOHVeQEXqqt2paSJZ}Omf z@=!Q-P)r`4E;oN!o$hjallLjc^*H(XNx3$SGRl_3naG`Lu_v|99p-QM_MS+QXOdWCp+H19ZI{Z`>H*1$i}o7_`1WnyAxA&Ep*9`3`Pba@yqDUFjd2qYJ^WjgvqU=xOV zyF9DzKTu5>q>e(H)TNlMf6!uzj>CUm8l-*m=h(a+_-JKFCokwtZMq*f?jEnj))I_j zjsDu6S52#WB0V(ef87WZ+U4vSGfG_ZsWHktQ*yfwu3WR!I48A}jjvtklBF_m)=exM zB*XNkc-r-_I^^{ooa+f*m~i*?0bvQ%FCJTgn*Xz!XoO2sHprM+8Nl4*@BU`dgU_p1 zU>0{gxKHGe)KuV7(!|&Gcj1YlTDv^frYC$$R=?n-EL8dkT$dBA+&JHKu&Mmf4{%-Q z+Ta@+R#8|Tna_|9y!PU=In;<3u-Ij@HEu`N!>v{!=`Z!3Jk{)*{T+chCV!QJaW8st zg`TMZ)n`~I4_j1A$?Q4A+nY=K$71z5*Y)~z)D`G+}Q$5>K#?tDI- zSmNT}qUyctc+;oi(>fySnV*AxH?2?519z#@Z>P^3Fu1;?begdw*I<84VDT<%x{}eo zw{rJw1?q6|F8Iv@i+j>{uYfu}9Kp&Pu}zAF0=f0cgfm^==c9n3xsObpqE*jObCiiD z$-Azah2CvKH|g|s(TMTC>uMr>SjCSD@0st4*BdLAOS>%QKc;D%2$QVm@#$%edD>k= z45H^p$0!00Qek)DlI`yu?jhPPM{W|nU=)_pJ`_vS`$M#oPi35&F)tgAo@1X6UbEEr z7w2~4YA)wz+*#L=3KUO@!f}xdxm0wXlsju`2ZgBB9PjMf1le07hPG_}UH?RlX5O5V*#lgPSd zMP^m`QG1f{Us)=uyNnh(h(k%os^kk8JpPP?v6kZx0zcgw)48hXWsyhF$WZX&%p2ZJ z=rWNkJoe|5%Um0>3$LEO5F(1VZS6?G1F2Bzh4cmWJ|#AOrtfBP>Z_H$3I4bhA>Sb8 zEE9{dfQCw^nrk8_zxd@vX2 zhd<|E^lQIo!Ju)Yb&xQy=o?<*O#k@vb#KWpu$RnS{Aa$i{@Q;?&&*P*Yv}jc*Y5oE zgHx+78y-pCX2kFA&&SMIkrVHo+V_4;n%B2AoWU)lCKHm=t-JhLq&>4*Ir!!e0`s5y z3H;~JN!qL}o_y_6DBW3L8MON9=BFw{7y0dQXK$1bQF*1#EIiuA8o@lKS-=)bgD8NfHzMzoL-**H|>F-khz`)_dnEW4dhT@_K`v`+gC!t>}DX-AyAjiW9{DOmg7A1@tD z|MEUF^E{7w9JeZTgc860&(W`l*R$ilQ=fFtu-%0JmHTLOE6a%lzk6KSd0FhJr#74( zemnCbR!Ko>)hS#g7F33wmz9-8YGg!dWF(OO1h;SZ);~KoZsp(qqy1k!yZB_f{o2yM zZ)M~6ktdcR@C^Rr_&@j#L+$MCr~f5`pRjr`IyxFu=j>*Fa#abpDmbeE5xf610p zBHdnw@gEK9O-v&!B&4eaU0bDLmsv`Zc~Knq8S@l6oScTs)yZM-{<8?@MtCqY;1#T|4Y#jw)lU{4j(+w3G#0d4N_mO z|Ct<)I$0n9H}ZSmp~CTM=W%}cUa3TiqR;%?Mal=cjmy%X?JH0L@Wnrul%Y!uP?CG< z#WfV~P9Xdg`7Hc==NfWGG%a^t3=u`U$1u*lFhR_?BLDJAx>(AY%paq&kDd{%WPYuwgV~?pcq6% zUkjpNM=1wzn^o-)WAY3oA>nJ7%&!}@1oy>`UFR$-SB&&#klL0M!D!W*Afz>B-3?QGCG=>TnsBAAPWpA}th|C} zhXgxO&kY;2k`1ty=hcT*xPXld%twU!$3eS*TNN-^2o#PGnt&t596&skHU(-<1|9X? z0S{Ax`KHaj4LXX@m|H<~IOx_Hn0ZE3Uo+p)ReYG#o7)u@w0tV`i0BmrSrrF9++I$F zL5r4G8W}4DMTSwE+|qMk7wk{)>bIx!sYKDsl)E|(v|ATXP1I6XmN#MU0q#K9T*T0d z8DF}UiM0tGQz|6XS9@gl9qqfur!_9g9tLvhC;)hSLcSZ0iZD5$dA>P z;~?MJA&syM(E=R^`v#SqtKkT-jTkuetWEHQ01o$aXXSFIS{a}h!~vd<$+v>0)*A2< zlYtzhmynPUo+=!)@GMToGpm|-BdvP7*m3bBFUjhz?m)!k^P1`?F`;cpqq%l_>=4n@ z)twSeqCB^0iA&?U_-J1vm>)ZeMa1+X(Zh6yam81QJRzWoYHJ5*ng^{{-G!Z`= z-e#)B`cJmCzP6xr+eF_h04D%iV1KbwJi&)ZBo?}kD${j2705@Vg<2iO|WBPgu-*w_IW>E%}0 z`Kd8Z#wVaPUgA+a^b8?@9D8$a0ICSF+EhDNX9rd~)r9RA#~WmT?3jQ@vk>54(W--e zfuN$(yUh`-VdVbqyWfx4zW?}9n_oXi%ii98iqtc2*`M`<{16}x#AEp1j}TcM(XN1& zfUQ`33YZ{)Ha@fBj&~}gx6gz+0o2{o(UD?c?t9txjoYI~%*TmXKV5q#r#~|oJoRl5 zqnmD4h+zc5O-$QC53Psa#mg>3Q8r2A;sH&e7*E~0%=ANo!>?XlvyySN(>=vska?+p z=uI!RT*vRGx3O)KVb;iTCF;F9O1<&1uUn71PYIhcy<~x$F_`|9*#=&AoP<-{QpzrH zOn%sk3RCcpfT8kGNLCEz97Kz^o~@l*^_Zk{clmr3DH3o4c3R69jr`}9iS@Lrc@NO zSb(075XivahX8~)vji}fyTFVDL=_1Dy*OYhECb+J=7KQYO5ckkh_X0=Kvcyp_&hMb zh6q@BKtR-WM2P?_>;(nToIuDCPXI(ViK)x4bn5_-wd0f97%1l;_5-{I)pD=dPWW0e zx1W~)-`QVnZ(EzvlWjoCDQN9{Bkgf8^h(j4J`n^E1N8y4eoRc_=PJqO$Uq=^x|sj! z9T4|L@*)k^okmA8($Y>Z%fTP(JrbWvzW_R6CGhhQ*AJ+YEKLrQXng@21{SZJp1!_c zBS5c;{8k(g_<5&Re^p@lfrkXh z=(f~(al@&3PPEc7DL;}^AK05T`zv3*9AeX!h`>RJFftkM(i_5N{vLp059!A(@h{ZF zfmyfHPUaGHK;~3kbF%q5GHjU}()wyvW~=i$cc`5PnITSZ%0@;i)%jK5R(tiqb1;*@ z4=!_n0A$4SI^b#n!~uxh#n9>*2!cjNiV6!6o(>5l1I#V5!Fm}E&I2|*E*o$i`q$2ySGhl+y^Mpvl0z43U!Yyy4br6y- z2f1FL%Iq1OoI8;8`=N8$^clpT)Poc2=b2^#Cvs|@J~w~2?{>^IdWB$veSr^I*EI)V zKz(Qx=^2=zi*W!?h=`M{p;uObzTit->*?(!em~~Y4f?-e$pwZ$Qvl)V5i|>MP`u^$ zk;7c^#*n?s#>3SR`I|P2r{}iHEnSDRUI*Lex^@f#QTl`h{W4<8z->?ThXa0Z?J?%} zjy5+m9KsQo*8{KcwCa2{qY9}XIbtjI*-TyUAu3f{;GBmHEYc#d!nTaXgp9C&@l%OT zhdkIJZndB<#;gD}i}oQfmWuYmI%NsL7=J%Qu?sx0Hm4RKg2;Ie&IE@6niy<9uRJ2x z5A8oH{W-}%$yayKCndO^spT;1D{BXY<DH!M>1PVZ`}88V`YUT|wbwA2U3AG!D3ZduK_;M7D86-+yjU|IR~ z?SKTE7@U49RXg+Z(1$f;&HHgLRxQ(MRI)5K0{MQpqE^Q|Nx!wkVEXjgopyXZg$F#y zb2F{4&rj^CmgQZ)b?^2Uk^b`AVTTmT%}V5)(=h(dCHga@U;2&??PN}T9+r`5H0ytK zIh)|+L&Q4e3k^r>S60}SJNs5>2>WkIIxS8#2y?*+hRyf--F6Wt!wl7rU;RA#X{`z* z3V&#L>I!yz6$D5~(*5x(fazN+;rTeB**pD3GQN9;di^W0L_>JqzO!pJzhaVv3Wrk7 z>-<~5YQ?7aWi%O}tYsbw^tL&-PkjJ^x8WwxDp+5rD}s0gskpN!{7wcd=m}H}6sLx# zrl+4NDlSHp4o@Qb01|*f#QEhS7rX+bDX=<9i$`vg#qHWpU|u2iF|1%9B2k~QgTw0w zf)f}aprR9nS6@TW+bT7-XG2W{=yXK@F-BBWD7MhEE*}BxC9F8$nm{H@)9+(1A zjiv%672OMjDr^C{$DM1piXWf-0+UJiO$H^%K`_*sWm)O=VSeWnCm=u0WZwG9WTxHB zr@rOd^0ew{y6LB)c^s#tE%&K(Bc3v0vE#;!ehnHmi@7r9g`!~ z+_ASwJ^k9XJ$m10U(Dg%ejc^eOf5BlBn^sX?$~y2H2Flz4)SUX#u_eR^B_IRx|8dSZt?u6jgH2^#PJ%K=elqCSw=&f5xUVS697g zlakdP{_~X?yM;a_<7Z+_Qj*iHxM2EAx<&%gH#7i533T-IqN+jgPDNcQ;<9cPtb{J9iG4j+^fQ;%KRag78%uB0gVUZ~4lB%LKK09d$C7!iwmURh@+)WFwX|O?Wlfd1^>tsMU%F&q zz{8YEd;Zt6YtyWKFN{|fNwfWN=f!4ozN``NWX~y@iWxG_HNRvoBuxv_nFMua8}it^Pft?0{#_m~1SmlULeE8QZu(S|hwdMAFg|Y@X8rZ2>U_()Y zT}5BPk{m6lamL2R%b?BjJN~XCP*)*yMq&VvYzJ@JQZ^J;^#QgqQb#E+)(>9>pF0J~ zOBpdMTkPi7tw4-~xbxaSm%iFZTtA}+!x<|5$%jl|5nVazz5s!3jW&JI-{?gER{{#j9JHu)-<0J9b^|e`S$hOV1v(Uz8BO*ngmP+8jGunoQ?!# zyO=nZMA3SCCD;0#253Dci?-S2xltxYPNh-WUYmrUupQjHuk`(izh2xwC)&Knqjf&) z^#MZg(_KE_2ulk-ic(4B36EBS2eT-_qt$VEUS8qdTC1`;_1bHDO`j^o1Ml|=Co1WL zi|uVVW+8f#Qz7sCV<)ydN81f!cvYAiY%N4Z#~EVV2z zH@3Qr+;%(gyos9XQIbgNE!OI}!4)N!;&h=d&G)-P_6`VR`xI3TTn_0cRntrsi`@p2 zmcKv8Gc;w*D}9^1R{ZN9tp<=*hwt!qPgEG(A%EhWt;foQKX+(|XIWbk?n^y?Aj}!& zm6G#@E6f7JyC>d6JhJV6M#4bxkNP@eZF4T>dvJyWD2a@3hi|c37Hyp*a88s6sYM1 zb{%i`9G4M$%o*f^{(&QgyRx#f6bd4uz{p?^`1OT#ti<5#LHC*bIva3zMioA8rB%9o z6AN{Win%5Tm^?r~VlvwTX5P9RQs=8N%u*x4wr`B604r~sY{Af9e0=xr6iBDkZ`#5B zaSsuk9!o^}cK%({C*5rp2YI zLU%RL>`i}JHk_AZIP^*}TFsVYP`9xxQTx&J!{Jts&P>lWwltNbTe<4AD!h09r#ij0 zwK*vDb?@IT(zkUlh7Zeqx}!EXFF5|>;*N}yjL5mc^7lNBI!U|{#!DFnKa)5aN#y(k zX)o4qwoVRq=+s9ZP#Ledfe8v#ppQgQF8cFs*_C$3CQYPz12lRrIB=e${F+rpSh z=bQ2T$C49yEg{!sa@H0$345BEnF&uBH_%+B)CQst6ERkMKC7uQVjLmC3=9m6m2fil z1nh3&V`+~`>(9ZPGO<`q_>o;9%B9N#eQc&opI<=m6%ETnG2@NCM80OAn)Y? z6{1Nf-S{(0kH5?a41oH(QK|cFRP9MEDUk#{w?<(MrD@Q;Nf2(LjW^>>cyV#@9(Jj7 z5N2AT>;3-ydyY}T8AN~lcz3$90t{;#h~hO-XK*-N%z$&bAc#S@9uhQ;M5l#MLc*cW z0UN1eLePcxF+aLVC^=6eRwLAC-Xta_0)O@_6mIpPL>K|AARDOM#>#p-1DPt3S0`N; zZx#aE<2ID9OCjFY5ja%fyEM5D?z91^61;&xRvJXBv<(bm($mwMlN1$U0NZtV{Q5bD zuaKfc6Hw8+1_$53ZV-eLQ9$F;U5Mvm+PXg6tnr$bf_Mv<$xqk8U6gMI*8vr zZv(Qo==mX?AlRVivHn9OZG(~}BVj1EAXAg7brx;GyeTpiVr}W`6zQ1{HSRz`Gk89r z18;s6k72O8*KibJr?GE`W#JFBxe54T+XqvD~X8BOZ90b0N zH2wEj0fSC8kgR?W@3pskr5%>a#{kMATi+gaUK=$76%HTQi*shZiKNNgCQr;@q+;6^ z-R4u?xeaxg1-m?x#Y=gNq5c5kaO3UTLwKs8!b*9a`%)muog%DH_*kB0JKcxtxjhm@ zok-vjvr)QMN}-2l%3Z7#7znzO;WRPaB78^LOGVx&py|75iS!YlUn3Q{w+^K%4wRKT zJj&{+jIH)_q`zU=&J^6gs>~=l%Jf<771@&M0~K8Dq&U`B9Y|hDNI!}<>KZJti;JNB zBC>-sz%>YD>&78BY(vVOrgu9o`}Sm;M{%Py2F1GGiiJ>@W|WMX22my`;75}rs}@3i z+x&-hn7=?yBLU?;`|r<>M9c?~%PFfR)d_W+7&%ncPddN;(kUXaf*?QN0B>&*EHNKa z`iO(zxq2d%d8mNR2b`u!ab-D_Y5>oQ> z&tp~$Fi|i+iGXfYU2-TAl0rg@OOS`}t%wKAGG`e}7TcY)K|^h6~ANhQ3&8O$_k9o zYe4+F9)7&H6!^MoV0j?NH_w-tG}r5o^URi*B(p4!=j|PcRvTyyez2Xe#KTPK`W`V$ zA>i*coN6N)7YGq5iNSBGoY<((ALAy*mi!rF7rS^bW}8F|^SDp6$rgO_v{dkI{Z)9| zs){kzQtD`_qYyCnOv;k&EFcR1Q+JO$s?tQ z@D(x!c|(0;Tg-{DtH=&t1F0<6skT3-k`gjAGI)lDh9IM|u071ODyT^=PX=2De^m<2 z5B~2=OuyCx=u1hOIyyQ~r_i>rNK#JGqBo5P`7a5FZwd}n^Y->e0VIzq5*1a*(CYL+ zs#XM0S!_~)`ttK*l6hsmC4DC3r2wc7oyF7UymcUE=)R#MKXFG68B~wYn|5%kf4x*~c0{=-E-H~<+)U?E_r*(73D4NrU$!NQ zN_Bbvm3?rDdRkMzMSFRF`;DBqwD(*)iqG_Q#)p&#uD$)fXZU=?@}QGlTglvDT<*%2 z#a2=DovH&b6;J=erudPPMjqVE^g&b91mg8Mg8U(-&w0}%QWI31x^OsyJcue-SWlCcq#wDqw!e6ufE5TWghdK%;RihiE*>A~VJoojixwEnOE)&q?Ae`M z$g7x8a5^kS4tMHScBpZ4QaoxE{op z;GLVljm3HVD9*jHL)?}cpc9^YPKqaV7R&w;$1W+bC7U{2t#y=};(z|)q>Zl2_(Dv=u?!XT%6mM#wSMBHil=^O9v z;S&B`dAr=ll*J$5s#pl}uT&N@2$ayS?Kam_BFpD|1Y(AN*-?Ho+Jq%1CRjWeOZz)_nyW$YmoR{*d!dM#4583QxaA#8$oalm-NXaenRq8NZ?crs zA4kddXP9!prISb^x4$L_EWUYl#NhA4PR-wRt`H)0j(HgBoNwZwb-mu3-3vnN%t*(>ut8E6wf}keLS@5(PTw}pbGAz#c;rK@E)Pa#gTWF zzLsj?%hp5RoiL|+baLeKW*Z}D)WZ^ToI$fME8OmyZNKX*%R-`d%2n{r`>)WY0}Vqf z*HK{->XCsOU}BJ-ovC}nX?q91tkZU@&w)D4V?pi5i8v2_BBm2&g10c&LqGmYz}PW- zJ@PhfL8}F^GM)~*LK0f6H~ktshzvNbE6vl}K&S<3fYH6Zz1NczqTK1}X;mV6AGBbJM5>V`$qIDIVZEcKuRWiKN)ypIt7Mn>*fs&#%RF;b*E<&VT&Texp zyedaXvo0dXbbDG^Zi43I8h%~6Ig&e`W4hjU{>L%_HIFuq zk!?Cw_tI^%BL_IvJl@)O|Gp=k!*{TFVnL9?2r4?m$ygwou4SuJTD&&B=T=P-!b;zA z)jR#~pZ=xn^J+(2cy4x?yTN>MXmpA%jmz^$yWEKk&Ytk;8gGS(o{>>?-?4{}*n8hR zmkzk9!Kpp;ueAxQldEECi#o4U%L>_fxcElQc^&3@0zBo`_zxDX(pGmB{Fj5D&Z%e) zD>A6h|CXM3ervs3H{QrDAyFwJPn?KN; z(6X$B?uH}L&kht4VgV?iaPDH4=#l<#Nltv6HwXGpQHp1$=)W&ssimL1MLlE^+@63+ z*s`B=crrw073|-A zvO0hKcnt^zLRS*K@B`pkn3z5*BKjc3vS*SDk~~G!es6W?+!(<9<{6nx{|Xx zC>auMlPg&Fr+_q8Gn8?&%NI5;tZ$q*Z=lT6v6lQ&c1d@|KMo#N_b0mKP8!3@(^GGS zDOi3Uw33V3sL8r0Kd`ay*?xP)SMPTG`8lP7>j8o-nDuWh_~Et9hH@~)1K8}tV~#QD<} zcJMN_-W6jC+7(-@V8270xd=V~-Qt_H4nP8=Av;^R=#{A}5`O}nT4^t7ZmtzXIL+yE z`F;(h#(#eLaT%^ipliU;Ob_UDHM=u}14ehFz=Q=-4R%m>oLHl+4hVxfm@0?=Og_=f z8ia3-NIILA&X*6b*deq<>duTt64M#QDIk60aVs%kv4O~?+9M6>#-`B!PXI}^{Pn@( zZr}bkDseWeeVuTeyh;WwOY;s!pPXV(Vw0o0cfT3I6K=?;$UzSU3{Poq;9Dplom^BG zhAdTZKk6vLQ-pe?4QP5mK6d1sr4mK9zWeISOpAXTA8V7%j{Z;s04zWYbUV9#{4ng; z&Lq4Iurx;$0(?AII7(=28t`6Vt->#8iM2qts)hqL z5Yk3WJ(SHXhQv{^VR)^#WKPVrdman<+!FK`<>6ILl!5*(0owiiJMh9^DgdZLDkiV8{%Ch_!Hd{%tpmFI_Fp`I{`^z?361ZtplML?1e+5(3nxKTpv z3|)G5*Uy_8ggD5cX6p`O6yrtMK&OupI8y7fqT+ym9=1U@VT@??BWiW#`;yhr0-U|9YrbZvz?p>F#UEnKIqo0|W6;m|0$4 zPAxB&g*VB2=oza;(3W36dl{mERQwiN^_E2SeG{cw%ui+n+I9J&7EwCu;y*CZ$n}&A z**K-Qq$&zp`?|}Opl|NpDowD43f4p!+LVHVf)FpLOAm%~qxwudLIF-Zn;-^jrD&aH zR2_pv4Ha3S{Pk8&K~>?`J(h7O-5dLIwn?^w5YK%S8C`X8amg#6D|2VE0Z~|F5jxx3 zUsnHqR$ULD?Z12X?zsHCucmVqWiqRh(STMhtcXP~*vK2T2z3`fozl54J2;yGbe*Ul+5oEO-fLt#QAM5OFG+I!x-%A#6R2qW z^d&&2YTU;{C2blbdE1DTX>x#hL|@3~PSK+Ixut#v<84X`*SjmB{iBDW3=|EuMO1JS z!fpCxJMV`phx^r{@2h6i1R=LLQkw^=r*Jud$+mZJxMpQ#1!xNXyax`qr={ToKeh?$ zCi9l>b08+Ay<>P`l6M9NUi3_cXbsBB&gz<)nkd6H@W#(()$9j7crR2STwH~#Uwo{% zo^tZ;f^NY!5HKg8MT(M{JG*UB&#Ecgd?oAPt`PLVFLJJFp$ZGPDx4h(HTVe-Y|`-B zZ+9&mGTXhBYLM2j0ranGIN;NGFbiy$S5U3sQDv52Zb$X6J`95vF|^N)Xh1WB&ggSf z0PaAmQ>(O1v=jglOVK42Y3vfHY`+9ok;}drC7+NPtgiohtav>f7XLUDlk~y7d#6=Z zDZsL&07@_tFcr~cR8&%gPS8zXf7^TMDDm{IlO28qyMq00`vqSpxtMp*bzztSaXpN& zpx8JtA3%^rrx64Io+{MgQ9TE0mMasM?SNW&4NXGESx(IrM_yHC*_jUkFubD?nt`4D z{c+&LML`^p0tLl*?~1YG4aG~Cn!S?afQ1tbD~Hy?QW>r?FTeuz6%+UYI>TGzqPemz zhsRlaaeY|6K3h($C@w4<2R+VuR2=KZ;gG3~G4nD)(3EIu0F$*V18hUZi=_EPUph zb$YgF`DbD1E{uZeKUER*&~(q+XufAwlZ%k7rnFF?)>_(yk4Kf<;nm`j(*r{HF(5!S zaeuea`zf8{=TIF5WwF*gTv4c4S`FAr?@w4{=P4Ep66Un*&Qm?tlBZ$AHX@VAoQd)k z7@*<=?Hc1bk0zMK2QkleScQuR)zb7HCk`*COHP2mEAL~oVwNsw`AzuasUU_LT_Q(f z;gWD3e2vm3sK==#PWqIv5T0|$%fM_69ki6Jd9Z04Ca51*rGNgNZZ!%GC;%Lc<@T-R zH-BY8BZ3ZKI<#1h&-T zrqx{UyB5^M9XzN?9Th zo{P3#H83#vC}_C^h1SY46I9DbwR3<)`h<_ORsX*1>svD0X7AzQ5dWa1+-avEbT1zXVHUg1^CgvN+10hOX{44AyICC~4+^}Z zx~J#;l|7TEQT%HTU4Db*X)p=^_<9Xe;Ac@Q;-jD;bT`)1E98a%7U&LQZZ;qbr5b*b zS(+Oey4s*M)T>89U^q7jlv$&jvIVD7&fe%#7F@UcxE;i98-5o=g@2<2(a-ctUGJJRojP>)nhq3w$oSgdm~ohj)i4w<)I)feJXRtZCAP5@}o4|*axdGY6NDsSwF^`*A9 z{5pU8=Z-PnA%q!c( z^j@&X<>(sT@?ddV2J7V;k%8qiGlalgJ3G62X~LV0I{Sp;eWR)XUkGXFxr4T_*CzJe zC=PK&&60#@`p8wrCrEu3SDy``O3mVR#lGU1{#KjM(2TE#j!V-(nEa|C`eCQX9-ffn zL_&uD5k-z(UeP7qG05e*#9T{TU(Qe*8g(-TH809Axypcfh?`e9lY9pxBNdQ^kQR&N zMtKOpoI$ zLJTkHk_0$eXmWQk6Ee!cCC_{q{4sY9x(kc55MU2NMj|^%Zfyia?LSXxawZT^Q%Hc- z7BH`ng1`vnCklXVF%W24O=3`zDDY|$Itxr4N)DMv2zUE#8_CqG#%uq%v4fZU4AhD; zvM?!-#(jQvJ+~^8Y2^QD?>pe4ywi0jn@vn&g2^U!lq90qQ4j@bCa#)M1dSp#iUNWK zP>^0@5?zgof{Gx;3IYlO0#XEwQKUx!1*t}gbOizF_jx|hNOsRXyLZpovwQB&FF%4a z!^~Iy<$a&>z86AQPt~5@r(juehn%5U&h+Qg0tpsF&5Zpo`(@IL95W!|utYekqtmn! zsRiYOoS>5m+mySxf8W}%CjV^-$&)uFlrJS-H%jvWc^P&#r*wD0rhUiDY{_^6L(bQ= zIfe13()r{ZgGq9S5Iag2Wv4;UevC^#w47T>Y>a18a5Xm8BG$`f&Cu|)>-KugXAMlY z^(ZgFs$+LFM03@H&QlViHeC|+X0at}O2>nqRr(ovl9WW$te+8Ef@{~&)Yse8+}4I{ zhaAlo=#%r8=WIB$ZA370Y?UAK+DLTPhE&&squIqY+$*-MtF*mJ*eIjq*)6BTwv*%& z3e{hD{z=q_S>P*4=2*9yTV>8pT|K?4jgYa{=ufFbBQ^kDHa32%7f+M~A;f$q(OqQ{ z!k*aB1S7OcP=cc&qX<3BT<*8){Ca+NTSI$iKD*WIcNV#Ry$R!otS%(7tPih5fR_^J_0Qw=qlJXj5oB{F}2-95_MAng!p@DU{q+hCtsj z&dhccLbJoFZ3WH1^rFa7hpR-bMp)k4s^6yoF{*29$;ELb22t&Ax`jHjGv2600n%ig zInH1zm?UQBpKkTOoIX!%sI_6ri2LN586YM0#N{~8WdF2ggpnueI;EPR$M-=&PgxG8 z46}sX-mWHSO^T9dSmfX`lKLh&7O_!h>hVbA38aSYas9n0-p-lgL`erG^X0@IB+3f# zD}+300bo&$PA0Mz$@Rvv8Q2ChiVz7T{@g!I3Ka62VqcE;m2JnHH%}y#lp^nNb8P9A zo8Lc0(A7+4kQ+^08MYBG_yv+T*I!(DZt9OJNST>*2A~G0G2&iS2Gw}`gE7ajYq@H~ ze80w~T0`8!O$%U+me&oun;qv!RnIoOeIA4&{~0iQ*4L9inYw3XhL>#4M@uA+0#dLA zkd+p*?pv&v?Z(izRJvdVmXbG4KSsrh@(jkg*jPNI8HU0%Lrdw0+?LkEwu$F9FFpr)yW--x!%G25N~pake}BA+A|wh^WKYF_ z@yGl#kKLy$50L>xz*1>QVm!>M+wM$o&qPy>lSJt4k)WM~`=A;XO3myu5xSKMn8)>L zIa=E)uvlkLBP5hK3QLDbbY(+g>t%~9l*sl2NnRT$ND7z}9yo&aHxqlwv@Jiw_W)d~ z*;9ADPeY*txmtKl&JDLlkYCvq9*XS-k*S|rZ-cJgqwke4*6`F^vxmnfE<3S0&l<_e zBS?JGBO(1iu;AXV)Nx*9sY$}!a^cbeUA>oy3k(SvX69<>h>jOgz=U;wnF?VRU>K)kjz0vy+lAk0eL?5mXQ*MzY;zmQeiY02K zA2(UH)B{W6baq&2hKc96$+r(nnAsD>V8m?&zsiZGWF(CgD@5*m^rP;3Z(~QAiQ444 zdhPKKj+?yLiv9iCuE!w>YO+uQr>nLXdy@0UmTxunMCe!yP)O8!XQE_lCb5jbq@<+H z^^NzD)gV!kgW6S}G#*-SXkWeFXdRxxtVjw#$X3+47}d4I&-(tlbul~E(Ogx1((o_} z>jRCMiB>6246)3^VPOV;FB+ndQ+6r!t`T~g2DyNntpsPI5af$ovV%e zcXFs5rxpZ3*{L%FPG?}zG~` z>qsLAoGYuEm7Mg%y!uJjG?=%sjtsOvn4siW3}c9 zeq@Ulm^~Rbr(@0-jD~SkkO3)UL@tL0nwutx48kvBG3Rc#>BsLVhU0eM2&yP$nVZ&m z>^c{(Y|9V13YZ1a$t4)Y3${yOAH)+zIL&<0RP~PqBI@E5m!3}G3-R$|@Tp@}9d=XV zpbK^&6nSQ zw*UL<`2EX$j!`Iy{)b`{6BCLv@z6Uf>^QKC0sO((4myiAp)-&Kd8;`)5G{R(4CeIp z>#A^{hKIfFri!;|7tb8=1K1O!n#%xBfdP}Zs>(btJD~^`JUQ6(YHLj{^U@%Ui?Xat zcLFY!g&lV4!vMQq_A`KHU@cG;aQ9TkAl%CIQ=XUHnI9gBBLs4);Vm~2KC3TCtT)p=bIA zV14Lat*s)<^M}@s=dYvUYCj_9Gm(mCNo#3EO{R~+wB^f}1N_uLO2bULwidB+x^ql2 zrO#8sL8s>-bhgZmUW|MVwmF$B>ljj%7cB z77&Dt5Za!Ce3l3UP~+-&*NM@uTc88ZGWMC#^jUdxz&nt7XLQfHY@T2nk#@VdB?JgC7>v(BnoDg>MZv}}icLBVg zLmS<_7O;^YX3&N1!W&Wc5^t1y*N3%F(lS9~O3ph%D_;cm-qYebZIDxRWz#}@$4umK zT1&uyjC`2%p(!k@&I6eOEj?*_-Wp0oZZaJNL|+p-vMe+gE?j6|OjJ=w8=&_lD`f;9 zpb|PKTcW!yMc6g9wdt@9L;9EgO(2Oba*LK#-X_9rc~{2w2u}{`<_soE7=;z<-u6Ld zjN@FY?r*SI&b#<@?>r-<1C-I=ZXH(Zd?JbaQ)!93qZ&<-v>*VXTX8-e3z24f&oeH^ zC?wGhCPt_7+T+6+oK(Cu$S1Nn?{F;PlBNXaV2%!8HY{KU@?%<&NrQ$4E@`Lt?0`-M zpxjLCp_WBvu|Vo(2zUeXJVXQVN~xz&=to%90t}oLWwqpYaFiShot>Se-P3Ho2}HVE zNvNa+E4y6kINOcDT}i7|ain5qsdc1jRRLzc1HrB(l9nxL9uw^W2w4^HN&@hXBn}L1m{jB?@}j&(`>!asdy=91#K1 z`Y`2kw>Ra0iBaiU)7UukK_8sB#z~OSllrTSM@o;|5QN+XC|qTy3sW;{>WlCpm8s5v z&DQu^m9%(TK4`z52PNN%JuPx~=%MQ@)C)?WE{PrFTF&S=dYC2iAJ!T#yE#45}qbur3 zI@Uh_)72e?k6G17xiiAXjR9l?c-^PdIt7xaIIEA=&QH|pt8p) zP~}(7Is<#AB_|_leybOb^ngpWL1p^!4m$O?vR$@tu3NR&1uN;*o6n}Ny>dG7Qn~+5 zl>MkeH4+V2;!Az)tGp{0tg?QxP0Mb!s_>Ny%nG9d^cWDXBIpe!*cag@ne`ZUCc%Z< za_f?2PbHaWgep3B`@X-IpMRXf7|wIjjyR#f_+^o1w=b|zh_d7|S9_42x~diSb$ADz z1DNANK=9VcjYsHgsLcx|=76kyg{o1kLxf8Ip)`ENFtPltl~a$~oV;uTOpoCtS3}6$ z6VIC27J&T;3H6V9IWB>0l0x9Z7r)Gd>jemU2GQ2JW`p0&XCd2QYwJNJ@G)Ln+k_RSSsSLyAzS5&m2u}tbyI?*BYjVFRe6m-DYvK-4cqls}R zzAq}AZJc0LQdsCc;E>V4QUaMn6cD859@NFSOdZcXWQm9>2xVdvb8{sN%XI((h!lya z<*fnL;JS=qr5J9;6&iI=(sOP^Isc_ic@>TtLu6)8cPR#bK?}MY9f1@;fM?uPK_VC< z-~n&18Fy)Y^wd;ID-A}_^&kEWw|X|ku^WN%F{)%7%=4wDh0iAp72;Z`gfD@yzX z{6$lQ?5H;kOgc@BYLj)RkI0CUZ|&FT2q0Kg0*Xn4wEqkLGgawu1P2FhZ5Fa~ zo#`VGo`rKF)OqpjVBk8ax~AcU(&VO~Ldz5zM=l?Ui( zITumxCA?c?ASFXKV8Qp0eFjEF$wRytCLjq^@nI){aVaLA0I#zz0Sl4x$vIoUh7!pO zptKPhmSN4#*A&Bs9YzJM$FI6+IR!=ACjN|G zI41~MK?pY*coOP#_{Puo?Eam|aSaF$M4aR)$aCI1FCMM9)4)2mL_nHO&wW0`$7u|c zTM6ckT=>y}YxMlx7h#>hq63|bF@gu?S4=BV8A;m~BfWfLiVUlwuSQ)nZNO~ISJupX z1IaE_1_pE?VQkb2sLQ~RUyG2MvPWUaqxjH#D)aO);VhC0M3J#uCKv=@E zD?b-K{SiR?vf$O6Uzjn~A9HF6kN+7YtN*+I!kXb0v|{>*0pW320;pOdT^n6{T8BGx znHCkO_Q$9bfC06@b3PF)x|($XAl zc=Z5skX9G&7Y5jeoPdMjvWQ$}RcnUXuP0$6EM2-W7*V=Dx3=mKQ=_2E8+w0rzD;iqIbvobwAvD2vw{MW@dBSCykTt> zX(o;*6UOd_ypmM5(8bBJbAgE4&NjWgWZY*jq6P#r^!`A2ks*>#2Fi@vu=1RZS0ZwZ zNNlx#TJUEs{W&DP ztrC@ir_4hdd#_?&U^EZ}xgf9cq8ArY=+{@lg| zAQClnN;*(IXgVJNv>6y%R|57&06bhNwZ)z!#&)NhVsW`=Gw0)4pa15tR$)CfuWx|3^)_nrSYu z^&-Lw(2CePZuQErWSj;y<51iyA@dw=*oi7^=TS8*w!~(GT8np%5iGcsmJqzGu@&xD z`CFd~bokIP$cl+TEGnpZnJ<+nh~)^q|2jypqY!p9e*WYxS19ZDNCZMos5+X{4h)T$ z6g8I_-vlCNON>|Ml@!VLqrrN+_r5>c<6}+5&xWZ!UqLbt(V_`*K&40DeJqEly0+06 zVYS5wSv_Br5LA`IWD}4DCS0&i(K1salw@`r=_c&OvP(Hk85Y|%788*rT!r*JG(p=A z!V6Dn2A#FFy-wndQ;$Qn9a|^NfRv$&gjqZC0u8;WRe1*$6ukw^3g{Uj+>*;*;syzM z5|fa~8JWA0A{fC7seN6zNp_zJpvILYt!-^pRMRmF_d9_oHrV0LOcJ!v7;P+dp;Qrf z=zv=OVWp~*ypDv(gcKhGpPWn*`U|5cy@Nv}u=d(b!Ud~eBm^z>RMrgOBsS{_p4EPq zu!DOA_8YA1e(!!fY4yA&17aW0_dfE4FuCyBE<*oP@=h2qA61d^E#e*S3vJj1( z_*I}e90QTgk2N5n>a79NFv?3CJ+#J;2(~Q`kOyAb<$M%5Ru@_%cG+m!WNn z1ep}TMaGCtIX)Oij}*C;gsq*v1rhAJNFy-=S|ol0k;du&x*;NS8Mz+<4=uPWZowN` zKv&UvnB;(@U7s(X`?r&g>oIrBFD&2;7DB1%O2RV60rY3)GGSEV$A4jD`dk0ntFJis zkGW_Th*ckFm>EUS{G~~~q4iBovhY5KeLvDnygJ#v_<4c1> zt>WGhB|asQH1+sEK-=*hrdI#sFNBT#FZ{n57+-$u|LYhtkiiOz$O|=5IE(Qp__jaV znTCnV?ZE^)Hf5dGRsw?Ba@5^PnyOms{zcUBh_D$t-a6;5=0jDT*aNjyE>Ll}Y&p{! zI3>Yzh5Y8jxy|D0ku0ydcL6}zl8}VFW89u$SBSMl8(bZv7kFG5(y#DJ#cehQL&DvM zKBec_`WW^#A}7FO3Yfm*M4ACG!FF`uHb^r7yOh;}+`A+|e87F*mr_f_GS`N;t=^S@ zGP%ex;9mir3{ag+m`YOQ~}Ab?xowa7B05d4SmAz+T3v z;Xo3q6J#2D>7Ib#u0j@Q5K6vwOEVI~g=M`FDz}t=`O}2-o^X@_D+U%eysMye8px8w zu9Bvi7VT|qRFl(rcO1kZiozHH(I@avyx%gJMi;nVddo;RP-T_gig2FP5(^BI;Dwct zafcNyS~WpuT!yu~T*ZV9yHR*{s#GdnLLfh@(bL-=0gGq#&f>zhG4@E}grkKnPz(MP zNEfZrrvz14hdb(GcBeasC--&=tj(>F`o0X8z=>hW*T4)Wr||&#E$#rsJvW%gO+>&; zh*Gb`=RpOUd&{b43oP!UGE6pK=e9y}e z)S>M)eUV*~O}nF~A0b&v4hgLv2~K`4Eb*`4+o2GG=OTkE|2{_c_bB)L@E9$&grzyE z&O?|;m=>+RK`xvEr>5idMS)a3B=-U>$$C4yARL^C8MzukRhYr7OIqng`L`q-*H8dR z3Z3^#zb9Y+0zOnsexYiZ>x+f_Q>r5PF$A}{YjE{L=8^7h0^b+P=T_8)hG$c@3)koz z`ac3SyghW#%$(s3bG-u#a#@k>ujEym)=9PF5;praImrOd5o|`QG?gxFE+aY&E;T($ zvvr6M;bW3S!c<3oVS>bjV^=`;QG_UqYD>~XECfTzQn5{u8CSKu;8j%G!fsp5w-6X& zCS4ui>cae#-UJl(G(z>s!#rChNO@yb&!M7CePjD1Wx8iXE5>%dx!gJ*sFCQ3GWrWh zGa%$Z2RkL5Mil|LWfKH=_BgQ6ebQpm8N4FFpESMjmiyowfU|A^`%$;-aG^wTqSio z`1WS?-qgN3V3b#zqmBj%d%9V{hxcWJI@O)YY0UytFZt%K;?nfv(>$eR9Sbu;PT6e3 zf>G%jo17dPp2U=|MdGsyDnC391KR;EEfRhRs*31>lplaqI8~-~HXeZ4uYRYLatnkl z)b^CbTMLewT^g`0hO=%ObOrv&9v_6wOxJ)^uP)g)VRhDzW@Pte4Pa0X2g>S3w7qHUPhqTu+tUkr=4EZ zC|qX2yf-tsyBXhJf$E#nhxl-M);1=Tz~2H{QVB*oi8pI>;6n&%(w}6jZh7?M_uR6u zV4KHPp749yffE#Qf1tyYC>!WkjIs9;C!&bHX;#L{F(#K-m`S3{bX4UMg5(@EsfEw%$AMgpF08F$XwXmM^kyFKM*st+KFDJkd*= z+j(fQ9E1^*GE+dU4BR0jN+(N*b{9r~g_Xb4<6$I15l`2=JSrAcD{Q!nd)-pf%ONeBGz6iDD`7hzGG-;Lt2**dFjOupq1Pwz^`sW?oW}nxu%CCt|6%?L~-4hwx~WV z#j@Etv#MARJK(VJf#_^aKgwk{ci_}Iy^bkk;bU38jGQwI_X*n`%r#M%Behi~ ztEZ1>p_S5IrTHsrL5RoF&%a10mWbJh)JO0dpg)3D?(%tpW`)L_O57GrH3-tGLCfC3 zr1Ni%n3P6Y6*q1MO$-?shQy&XY%@iSlqZBHm|$rZd|(8&p8cSo$yIctb8CLb?RiN@ zp_MeoatNth@c5Wz-1kwgel`h0LUqtEYyecM=E*wFj2S$PFoE-L`p2@yscw^l4z&^R z1951@b=HRR_X^j(ranJz<;-lx3(@qIj9L$s@U1-<1_mZn=0Mw94ed*~`P6BMH4%2O zHW}0rYIPGcN%LY$F)FHGKwJ>2nyKk1-<}aQ8&z2;Y%H0t`2u#RDv98R^X0@7LK6rN3K zM*W<6hKH3V5oNaDibjZ7Nvh8ZsWmcAkNQ~(E}+v0AfU>QpUdQ-qON#k71iAnCx}!= z7VXnQjES@fKYvS}HO|FaN9RzykI7DC2i0i0v6w=iDlBG~%-fV{Wi}H#)x6C$ZP<^cmr@fzSTK`#)L06#y+s z|2MMd|0aU}ml47J19t$?bNnOrA?)JeEalQ?hb;x|5+ebE zrQ9a<5#bInns)mImaqJ+yP#g?U9X`4J`>y?xa)QO-PPT^PIqGAca?;R+2cIP)`(wg zdQ;mj6nR2=&sYRThwDJnj`uad;(*s))4d-9M53*uz8%4H9IE)xu<%mKX6=RONk~$$ zHL*-I;(~tl6j7^mHUO&%Qt#O+7^MLyQ-%)j>*@%1Wbg+?3bq41nl0cpwCCn9-NzD0ZkMo*2OTPLh&`H- zm{aXOybH|A-9`tC=G~N$A^@ zT#jRkvtW}EG8O`ga!}(*+7xV}l#q@KHsGjy2GWk`EK4j9B1OfREXu}N?3j*H zbWhU#=)I!jNkAG{o}KPx6GVxXT>z}pk;B2hMc*gn&D6~UX=FW%j*M)`XlDg<6<5oF z?BusH??pV}MR}lAK-WgvF%CFOi&<0&1$>HK`+A7VGvZ|3DGYsLmmborlRyI~>(n_Y za`>jG2_b{q$Uow5*+P;m(&JQjvebyA_>2h*B#b|rQ50lWlU=HEGg}qeXKW~CAuB4l zE1ije(MUiYHb`~XJIst2xCkLJ?8_Bs#-QQo*cH{Nj06q2`ScAjmz?w|rU!@RDb4&8 zX_2Q{lo}HX+AYWw>=>%Fv- z1`v_}dLZat2hDHX3^A%4Ti$qUBt+WLXUFp{mS1=&MtMzoNypY!=q)K;9*{WNMb3Dd zi&Y2U|3>@D;~hWmQ#*#`jB8^@ceQ!|BNJzLxb0R2X=c$B!rC?+0It0mq+=NoGz(Oo zNJ==&bq0Mb=hFQ`1w)zW8

FZju;`0@?|cOEEg8h&@urJ5Hyih#5t5q&+nN7+a*p z^YXRz93G%N(4Qt8{t75KB_em%z<_Z>XeRCEH5FkBq3e*6N^MMo&^2nQmGd8$reKnqA40{aKF3T0`KN_6;k z-iPiAQY{qTSVbDDweEolmOLforqltOKoZLk9WiwzfGAcvL>f5Fz3GAa<5kgEQ?-SD zw4y7AgqU679Ev?LA;#=$AxKGc7{?yL2xtK*y=^_hl1Sfuta%4@Zwdts1!U9D7_!IJ z1fFVUeFJD39Wiz7z6;9@)G1uo>W?6Wvh%}fjn@%bXHaKZ%g;+^nDf(4JlN zJ4Dev@_)c}rRx@)7EAv?^;Zj`B%OZ!(12+0BHV8a;bB4#$zzo5$7e`z6#<$~OCBUm z={@ab0mR2ystZaC><83}G51DyvpX=;w*W3>VQzXmC{yJh0@2CB&PDluA|f5A(0ih4 zi^8!KfU;w7F&3_*=GnCLVsWSB3wbmleWUV}Zz^{|Dx`$6Rf3-k(y25a1F1K5c)*V3 zM>`MzFI$Rm#?vVW0IzCFjy7Tq7LwzQ9mq(mFd%en-*j`%{#!QN1!m-?N>OKJ3zk4i z5?BYHRoIv+%R!6+-D{G{GW6zkX8E#8B<+;Alg#1n1FcvbdNH>6w zRRLyY0)q-k;J^^oe=HbLp}~=+eCI#X(M;Q-2|E6=>*By(6#9)&QC{_z9jMz-Pxz-& zI}5qrW^R?#UA!0Mb2ea3V#5O|UDe7}|w3VvV(JkoeZ7FIwW;E%mFGU|O z{N&7sZ%xrTGIY&*2ZkBd?1EL=WPd@3S#+p&c<7#tiy7mG9eArI*dcFKw02^{6w}uD z+J)Oze6e8lAA|RNcPt|B~rlD4zA5QS<3B`_RlkS!L8Kw) z+S?7`&IE3N&hOz{gq<_Oa0%v-egM*m@>w#1@^EC1-EH|u_}00oAq*8Kl!VE?Au4FX z;l=DI6!=1L&-SvnHf$~;vm^-L@RrMFigjf!1Y^k z1yhbL5ZJ7}5vSAGxV``3w)q`iJmK;0-~W9b{#nO>o6Py#8bwL17q~@kvoWagFbL0k z^OMis>sr+lZ%K{0WSlU~MJa|4b=Q9Ft|@uA$G*OCJ+xEhcRu61Mj>`769)V|%s8 zY@cDcduMA@f}!n$&+*c}dBQRyy7I&R*<#gMqld1Xw0FwoHPbGy+3sa<>yh0hyf5pg z?U%;S&7UBH_e*FE9EC?`m}0^YCvSg#EaKB!j|!wxf7-s?c2{m=*h;=j{SeY|l%ocSX9-)^-W2Uj$#Y#fuWe}JZx)3&+A2DvrrP5a~7a|cW#Vm1IJ%R8?bwpFL%ybaoEVgrx-9_r>r zV5Xnk)~f&BQ2syR2k+Vh`G%Q0LW)X89W2z9Tq zatZM{{jS@$TeW8A{`B~vg+;nf)A-Dg8J9b2u9RiEeyyWt)48CucTb_`kAZIvR}hxt z^jRb3Xtxp#7r8m)n_{By zhaxv!8yMcWeP9bTfkR{wXae%Wq2EU<7#VE(p3?&0O{tnA1@xlxg5*4>{rI7Y9(eDLt$ zE&2ETlpm+8-Vhz_clYjH@fV%}+i~3_OY6B-ibT>0afcrKv?2S(jXBRwSbZG6auc?q zlt<$JS2ezN_wmWv;{2{g@Rq2!Wf0`ywPpRUExA^5>odcIvVt@F|8TJ3=c_@w-Zt~F zTc?~Ectic&wRyiElKWE$nrw;`y9dr){N03O*$PM>cG`VR@3u(v`1Y4aRZAWYl?dGb z;6fd~zWnLL{=q!nbLV&8W+{L~-D&hu|C=49rO$j4wg}gJ!oI&FCRl8Ue1fs_zq@2{oK#*;qx1=5(C!kSa@igqt`@KD6NK2&G_347f8+Z^~z!cS4rD`}G zg#G$c)~uDEHR}ikECdNz7E;~VW53iqF*B#_VR4g$TK|wf$%ui2c(ItMeeS8!W9JUf z-}YmndtaPWNu8b-Bq!$g4D)8Jz4B=pYLZj@{;U-MM%=U8V_9M2ftMgNX=XhTdbVkE z@I_It)`>&%3fcoAnp7KXl(DFsJ9lpMm@(<)wU8y%q#NS0fj?sV~NkOvTJJFV-9NisBgb{FCh8a(}L%_8#D-7qU`i3UQg(dAS{wZf`eVpC&dDnZ6`C2;ZJKK5Ocw|%B+J`#u zkKM0ty5c=8se47zuJ-$#&CgOLyBsW>4Xz{^B_~vBnHU)ud`j+-adv$C*V@`xQTp1g zZ2Pi`Ht6Wt8rd73mOK=deAqt^2%cW)0P1Z6CBZFPIJNd;R=owt%upkAZ} z%&N3Ga{um}F=;=_Xl2v|D9ecD ze%7fRC#$Ar6_Dq-Cc$%q)Tqo0eNRJun@b%nE9c$c(i5`gLv_S#u# z+Bd0ss`d6IJw7U(W*X|5+;hKEY?afSf{MCpcGt-cvF#Xh`|vWgXs3G*-`u+MnjZ$V zrB}|T(&6eEQ<|>J=yyM>>YQSqy{fClB-wEXVryJ%MPVyX`Rsh*j>D-6RH+e`m2~ZX zCKg;U^tTY`y;{`JjdXJ%7IBXXiWOMU51Xjae`2E7Mh^(HS%gWbnXc9MT#Ln#`5tVk znw=;29bobeuhlXSCp75GT&CCT0f`R9kgUJhf?nk6;%1Cwc~oM5pr-DnmB;!&5VZ!W zWx7Oe$uL!GZfNazku_Fs&O^=ouX@8j4wMmRJ9aMcx7@AeVN^NcoPD*|n(CsA7-O7y zpMCDlj!d($a>eyV>$Q&mFe3YsYV*;c@Pwl}>RnHKqYV}#slnM#?mzoY6|82%U7i1feP~E)$V#rjk zW)$i@K-u-jv9XU+uGwatD~vpMT)L*NG(qay-ijM%R3e>CuGwTRX#dDLA^PqbnaegZ zGMTkm0lvvr`&dTs@CATl`$t3dPfD}>#4+lG0g*jEuRxgnUd?Wdy;vi# z1%)wY8&1fU*>4Y}vMo2Lsrd4ivrvLm0?RzPduZ&!ERrXyv{V(u%`vgpXQ0%vI5b+` zG%Y;24XwSgAyrUc(q0qi^f}QIio&$D1&|v2h-|*s6)9E2#;Yn@7wdQ!)tyrg|F)>l zYSu8b($OBO);1Pu-6260aav7M>ecs$yxmeitk}z5r+a#`V^+^-Npts{aa+e1&+#y> z+nZ}DJDlCy;9&~2dr{;;6ruCL^9?Nq-bMA2A_-Sa47<*_j^(600NK6tF%oWPYLUh9Q zfQDFNbzIp1EZTluJg4fJn%Bpw5N1KdSvoUdm8+{OYfg1GOQ$VEMP};YFyDm|uV_b~ z?{%c&*yFCq&V@M-D_=ZI7*S;}^Ff}M7AN%w$yOSr&l9ZVt_P)Kl5TK`YZ-$JP+e;g zmJzaKG0wiPeSc3}ua;)!fLLj`?d`pR9|kX$Z>m8o%OA zXKl6SAu{#N_Lp9y)Yzx~U|YIv*V#479o_BojO)%_mQ=VrEnL;s#$PQcJc-sRiFXF( zb0B6G>&IKN%-4BO&7~d`eF$=N9lLhJXcQ> z81a~of%?H1pc?02F)*-5WTEm#J7 z?6Sr}AFbl!Wcc)t?1X|cQO z`Of0@c~ya3Lvm}RgT|M6%~Q#lDc}(w44f89?M0zT3V^(y6g=LbQlE)V1Uvwc7H+s~`M8h1Af$$nSO=3KSA}X_7HpB^5 zS|l8_%85U^o22GNZyP{t+Ya9ti!y)At`B*Ea}P?Y zYuwnAW!+l&Sj{?P?h&x-uvv z8+rZ7B0xB4paZ#ucK%`fzWs0H^~D=6#OY|%1taF_tXn3tF3Nb0)W*oUvfWb2cbZ(A z)mv`rn`s4kW;%bZ)%zsQWb}5DU{a%Bs1P zhIW^f_2@dk+x5h?EMiLN_|aky-)F~a?6Z`jQme5BcC70;V`6-3h-S`kStXYr*D2rh z+itNjp!M4_&-;?Wy$@`qvd2w}krDg15w;yImvJExuEojbie-tm8*tww6|PSUcWAr& z*lK(1;lpL?8!x>jKgrKa+POqKzFzI>4;X;G&m6}_A-YX!T)aiI#n71R&UDunrh$sz zaV&*9H*EKm7Q#4mQke+qbeRW*(L9(IaCA!IW^j1wsghAGG3ywq35M>22P@dvx3{OP zPZd$WJ>+{N=}=cI9C*N2M7(LfKKb;cVdKKlnS?0?G0JeeThneQ-$q6&G}Xn5kh^0p z#edbdF4eYe9dX6_0R9ACG|10pz4HMX(_dyT&v8h9p7_YtrZ=i9-F3%!DfOnBp14O^ zTA4#N{n)<4dr9?WS;e`j&uwMICoYf8)(zczTdL)&_CpD=Rb?(Q6EC;*CbSX_soyW8 zkL4fU!`Ni+Cf7C-*AvnmKgp;asFZHg3-((n+VyRbcfw;--0lPeG_RZ&$4 zxvtf-TO)(e8wHs`4j3#JK4M(U^J2-ee6`w-ThJU#{?KAWuXD-CuHm+m;vpZfQF5)n z24X=TQ41~AXx7mepYecI^(cBIZRAlwVuaVw*=1%bCCL3dUC!fN7&spI<^VDu*xquNy2LbI2%v!nuz6_O_fEZwJ^mr_9v9S+Fa&NXz?bOi<|2 zzUs4&ZPvvGg?B~x2MtT!)_Xqsg-WqOkqy6wmt)#cg8l9@{A6ggFn9{JG2)<$W zd5*L44=Jt0(i?DxlQR(k%5qoNz8A%$Nm#GDgMC4Q)LMf*vy~COwV?JYyFv$iG6UuS zS&9tFded$M7&*@u%Zp%hVg)fs5AMCNLj=%UKCJiCzGqT>M^Rn8;qk6}*bTaJ$t)Hc zd*$!5-!rM{-kce(yGyfmeR_MUH?%ans~>UgeH3`9=)OkF17(Tmhkhz51(_pV6P3Jr zA2&NW7RHF(mox{A-P_X9WRZS<$%fV`IGKX*F`zJOS>s78Cl}cw`@)cHR&Q@F>0kuX zeshM1mh3^{md5s?r2y?}cK7C(ZJh6?36LE>*I!=4v<34UEuppM4-xrajA&9fEOW%m zd~L}Rd1OR4Zrt$gYgeufx^XP-KS)4_eNvZjQFZ)ZoqyukMWXZpz>vQm_#gPa?c!b2 zynW@Tcg{c-DXH`l*!+XX&MR?mBZ59mU>^^I;l*4 z9~?4Owqu6+(u#7sGz;zEkZoUQN*mzd6can9aPW$bEQ<&E5$-IK1D zn8)J(_qIhdvw8IB5iA;Z_mo6e)DJojtKB{S@sg-Qji|!Rr%!h>9y)X8%#Na%8C1Tb zOwCbXiZ%(VHBJR14Zi`XL0=JbBpe2JW&$3VSHw23G=V19{V`BxjG`_C9;|u`_Qy_9 zA!b_MNtsSw))!fShRNbO1KnGhThM-bOO#TtDhldNR<^0XgIG4oxvTBjX>sOHy|YGq zWR0eQfR0&tfodF-4RB9gYzR!Yz|{4-#k^Z?a=5PsvI5d8d}>Lo4DbA+fwQ)$RI9_wT?UA@%?uO&A$j?@ME7ZSBJbv+7#+2w7nW8h;3n znjU`4%E!MYcQ_vK!F0r2Otn}zZ|>ZfKyzEeSzBy({k-=4oDm9#O7y~lf=rz17yP6l z0F<9B#NBuN0`-qp1KnJ)r)1Ic1{+6}&0~~nXM6r>zw=7>y|*`?jevq?*f}dNS!((FUbUaC>d`6~+4|Mi%>{Fw^}IQJlrZ$uKSh-b?1 zlJEM>bQtOkf?c&7E~(d9+h!4odHCz^ZVSKL5P@YuQExkzf@8d>qMh$N`(ZBdrkw3)Rq8e{z4<2tjLv&oV)1)$ zwE_T=lp6wqtoRdxuTE4o?7FiZ>A738aD4AY29=1$9J>7?0>-aSn8>?KA(8Xc+uo4v ze;Z$?I7_&2-;eEj>tR6vU7cn)iLPz+(b_!b~RKrHiC?G%gwcZq+1_7`IS|Z_TzD{Qd3J|60 zn=HD6tG431+fY*oWq3Re9z3YwtB9)CXfkpxXPQAsd5o zy`S48lxX6={w0IfZ%nNBKls4d&Q39Fi#(Fb?!^7oZ;B+-CY3G@yKBmlfuL)tvcMui z8t{LY3)GiZ}JWRCneDDyl;mX{fu5A4!Ok*<_rf;{a zn65`LA0cfA@QM6&(hMB)&hx@b;Ba#Fuv6o=e*l^~Z2Y9<2fb`Y$~81PV|)be9XGI~ zO}^Fmy9@gbTRS?tM4}}j4!+M%*=%}PLd0*?%Y|iuxb2fE4O=BqW20!*)vH0fgipZH zw(S9f^@n#)f&-NO3`1za=-Yb&L)0*!7Zi>;v{xTJz3PYPQAu`8V0U)@X-wB!8+)+B zMt0Us45x!Xz3POO=;^3pOQGj6UfFi7^4FSel0q_%x8b6`|L@=bS6qjKwWy7|omACE zr)RfXm4v&CA8u2$;q{V7_)!S;i1WaBz=&9?O)(xTM^smxvIxt;aO!_(1lO`;miwzG zBtOY_EN$^8YRA?JgWgHi+KCw(A_+gd8el2MLV7y_`jprZiyFEIuQVK@z}2m2LdX_m ztsL*d!)=OAPn$)bjUL+FRDCb&v}w>$BD(nM++9}u8uT<}Zzra&^I-Nm=xp;}6?zd# zjQC$PxDQSh6EnbFxTyB(6lYxsVHZx61konPH?gLG}$R|kz5=nU%&Uw!kC zMb_|j)7Hx3#e=?qHRw?2>!VnI@Y($jiI{D0sdpeV_h(^&zv>1b49Ts@OMUqXB8m5u z`(qV8!?^6_EC1j9ei4(my#h$%F7{6!G)>WFD**8atJa4Pn%C?Wz>?7nyI$R8%xe!L zROm)jK4`s|Dh6XSAVc6)mfWq)?Xdq*ZZJUj^Phj!wI4Oihpk$d`@gznCpY)=t->e% zPvp4#;(vUe|{?ws{K`y>u(Jc zuv&pu)HjB<@Ac0&-X}l-yqtfYEAZKW)#vjm|LtUaCp=QV*!0hQrupz|)%?pG?Qe6+ z{!9GdGk&zSwJDW7Ix?~Rt=9*x_Vrrr@N^V;2g5(;p<{)#rl zMBuG}<evhK_9;! zB}IuRvkKOr6L4k^9z+A^?Xv3i!^~&JAT;oqMw{NCj|v$!Fq*NV4`6tF;=kv@|08em z-}+to0Vv^W0*Az)m=)zKCXh@J6xpwj2-YHjTtlUDidOLJD>2KxkW^uL{y{4-zi-B+41md@SwX0&X1k1vrs;E-JprbDi)~ilArqO;s$PvMv0Es7v_};r?Lk9 z8|+EA+S~n?9FWY4)fC0BMsug)%O_Kgh4Km7-jvWzY|zk&XWbj%H8kc8I!aoJsNhIK z52X9A=WE)65LNhW#kB&SCj7o&_P_e?A=0Bk7wpj)nIKkBsr4d6cjzG)WkQC$vM+WpICUq$!^$z$QrRBt}DClJ~I2w+$?Gk;rt z(5v;%{|M+S!qHuA4g8F~`}RR#f*RM8Ud!FPN*=!_P&bPgM>==e{0iw@DFbLsQN{-! zOiZ3)y3_*`n)S=W6lvuFz@_Yr4o`@^(@KB89CjrwaA??A<-yu&2Cbqs zs{iry8sw=vO5zo$8i)H20z;4buRp_008v1=-N6NZnOM)NPcM00+@AC_SIh&OoK@*L)Nt;gNOF*x8IJIl+1kc#MI+_ z9qKNXcX1H2?%6uTbhx}d-O^$J`P$ml{6I3uqHu)PLBD$aE@hJ;s5nR;FZNfahjwQe zsOh;DrM{F@p}2Am){X&+5Kb=X8Xr~1wk}X&LVt_B-y?AFx07f;tVomsRuhZf_?P9 zYH$|Bvt^d=nSb(RCLWOGj&}jVkQP@tB z1$*lhT&9FW-X7;kt!e@lWO+F@{i63gE>+j(HuT!ODq!xa-aIhbAe`3dpy_hIq@RRKZbp2u;>2(&&0bI literal 0 HcmV?d00001 diff --git a/screenshots/edit-user-warning.png b/screenshots/edit-user-warning.png new file mode 100644 index 0000000000000000000000000000000000000000..d6e1dc6d9b3c866e1e3596a41f428f68fcd7ce63 GIT binary patch literal 116999 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

(+Hd)8#%zg9pbnjSk$u6HN(^yDlq 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

#6Ye}Ejr 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 literal 0 HcmV?d00001 diff --git a/screenshots/login.png b/screenshots/login.png new file mode 100644 index 0000000000000000000000000000000000000000..44fc8106a2303e31f88200b8590a7fce387c0531 GIT binary patch literal 42760 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 Date: Sat, 4 Apr 2026 18:33:21 +0200 Subject: [PATCH 17/24] Update ADRs with is_admin authorization model ADR-001: Replace "authenticated = full access" with is_admin flag, add column to schema, add is_admin to JWT claims, document admin-only user management and self-demote guard. ADR-002: Document SAML users default to is_admin = false. ADR-003: User management page restricted to admins, admin toggle in create/edit dialogs, admin column in table, self-demote guard. Co-Authored-By: Claude Opus 4.6 --- docs/adr/ADR-001-local-user-authentication.md | 17 ++++++++++------- docs/adr/ADR-002-saml-authentication.md | 2 ++ docs/adr/ADR-003-user-management-ui.md | 16 ++++++++++------ 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/docs/adr/ADR-001-local-user-authentication.md b/docs/adr/ADR-001-local-user-authentication.md index 4dbeec6..69b9deb 100644 --- a/docs/adr/ADR-001-local-user-authentication.md +++ b/docs/adr/ADR-001-local-user-authentication.md @@ -23,7 +23,7 @@ The application is a single Go binary that embeds the Vue 3 frontend. Adding aut |---|---|---| | User storage | **SQLite** | Enables user management API/UI; single-file DB fits the single-binary model | | Token strategy | **Short-lived access JWT + long-lived refresh token** | Avoids frequent re-logins while keeping access tokens small and revocable | -| Authorization model | **Single admin role** (authenticated = full access) | No RBAC needed at this stage | +| Authorization model | **Admin flag** (`is_admin` boolean on user) | User management restricted to admins; all other endpoints available to any authenticated user. `--create-admin` sets `is_admin = true`, SAML auto-provisioned users default to `false`. | | Auth optional | **Config toggle** (`auth.enabled`) | Trusted-network deployments should not be forced to manage users | --- @@ -61,6 +61,7 @@ CREATE TABLE users ( display_name TEXT, password_hash TEXT, -- bcrypt hash (NULL for SAML users) auth_source TEXT NOT NULL DEFAULT 'local', -- 'local' | 'saml' (ADR-002) + is_admin BOOLEAN NOT NULL DEFAULT FALSE, -- only admins can manage users created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -137,13 +138,13 @@ POST /api/v1/auth/logout – revoke current refresh token GET /api/v1/auth/me – return current user profile from token claims ``` -**User management endpoints** (also in `handler/auth.go`, require auth): +**User management endpoints** (also in `handler/auth.go`, require auth + admin): ``` -GET /api/v1/auth/users – list all users -POST /api/v1/auth/users – create user -PUT /api/v1/auth/users/:id – update user (email, display_name, password) -DELETE /api/v1/auth/users/:id – delete user +GET /api/v1/auth/users – list all users (admin only) +POST /api/v1/auth/users – create user (admin only) +PUT /api/v1/auth/users/:id – update user (admin only) +DELETE /api/v1/auth/users/:id – delete user (admin only) ``` Login response body: @@ -188,6 +189,7 @@ func JWTAuthMiddleware(cfg *config.Config) gin.HandlerFunc { "username": "alice", "email": "alice@example.com", "display_name": "Alice Smith", + "is_admin": true, "iat": 1234567890, "exp": 1234568790 } @@ -358,6 +360,8 @@ auth: - **HTTPS**: Access and refresh tokens in `localStorage` are only safe over HTTPS; document this requirement - **DB file permissions**: The SQLite file contains password hashes; restrict to `0600` - **Self-delete guard**: Prevent a user from deleting their own account via the management API +- **Self-demote guard**: Prevent an admin from removing their own admin flag (avoids lockout) +- **Admin-only user management**: Only users with `is_admin = true` can access user management endpoints (list, create, update, delete). The `is_admin` flag is included in JWT claims so the check doesn't require a DB call. --- @@ -375,7 +379,6 @@ auth: - In-memory rate limiter resets on restart (acceptable for v1) ### Future upgrade path -- Add a user management page in the Vue frontend - Add TOTP/MFA per user - Replace in-memory rate limiter with persistent counter in SQLite - Add audit log table for login/logout/user-change events diff --git a/docs/adr/ADR-002-saml-authentication.md b/docs/adr/ADR-002-saml-authentication.md index 84c4b93..8840cf7 100644 --- a/docs/adr/ADR-002-saml-authentication.md +++ b/docs/adr/ADR-002-saml-authentication.md @@ -90,6 +90,8 @@ ALTER TABLE users ADD COLUMN surname TEXT; On each SAML login, the user record is **upserted** (insert or update on `email` conflict) so that profile changes in the IdP propagate automatically. +SAML auto-provisioned users are created with `is_admin = false`. An existing admin must promote them via the User Management UI if they should have admin access. This prevents privilege escalation through SAML auto-provisioning. + --- ## Architecture Changes diff --git a/docs/adr/ADR-003-user-management-ui.md b/docs/adr/ADR-003-user-management-ui.md index 9a0d482..5c19cde 100644 --- a/docs/adr/ADR-003-user-management-ui.md +++ b/docs/adr/ADR-003-user-management-ui.md @@ -17,9 +17,9 @@ Currently, user management is only possible via CLI (`--create-admin`) or direct ## Decision -Add a **User Management page** at route `/users` within the existing `MainLayout`, following the same patterns used by the CA Overview page (q-table, q-dialog for confirmations, Notify for feedback). The page is only visible in the sidebar when `auth.enabled` is `true`. +Add a **User Management page** at route `/users` within the existing `MainLayout`, following the same patterns used by the CA Overview page (q-table, q-dialog for confirmations, Notify for feedback). The page is only visible in the sidebar when `auth.enabled` is `true` **and the current user is an admin** (`is_admin = true`). -No backend changes are required — all API endpoints already exist. +User management API endpoints are restricted to admin users — the backend returns 403 for non-admin requests. --- @@ -41,7 +41,8 @@ A single page with: | Username | `username` | Yes | Primary identifier | | Display Name | `display_name` | Yes | | | Email | `email` | Yes | | -| Auth Source | `auth_source` | Yes | "local" or "saml" (future ADR-002) | +| Auth Source | `auth_source` | Yes | "local" or "saml" | +| Admin | `is_admin` | Yes | Checkbox/badge indicating admin status | | Created | `created_at` | Yes | Formatted date | | Actions | — | No | Edit / Delete buttons | @@ -54,6 +55,7 @@ Quasar `q-dialog` with a form: | Username | q-input, text | Required | | Email | q-input, email | Optional | | Display Name | q-input, text | Optional | +| Admin | q-toggle | Default: false | | Password | q-input, password | Required, min 8 chars | | Confirm Password | q-input, password | Must match Password | @@ -62,7 +64,9 @@ On 409: show "Username already exists" error. ### Edit User Dialog -Same q-dialog, pre-populated with existing values. Username is read-only (not editable after creation). Password fields are optional — leave blank to keep current password. +Same q-dialog, pre-populated with existing values. Username is read-only (not editable after creation). Password fields are optional — leave blank to keep current password. Admin toggle available — but disabled when editing your own account (self-demote guard to prevent lockout). + +For SAML users (`auth_source = 'saml'`): email, display name, and password fields are disabled (managed by IdP). Only the admin toggle is editable. On submit: `Backend.updateUser(id, data)` → success notification → reload table. @@ -106,10 +110,10 @@ Add route under the existing MainLayout children: #### Update: `ui/src/layouts/MainLayout.vue` -Add sidebar menu item, conditionally shown when `auth.authEnabled` is true: +Add sidebar menu item, conditionally shown when `auth.authEnabled` is true **and the user is an admin**: ```vue - + From 2bf658515855670be185e2f17561f77b14fe89d9 Mon Sep 17 00:00:00 2001 From: vitorio92 Date: Sat, 4 Apr 2026 18:44:33 +0200 Subject: [PATCH 18/24] add reader and admin role --- db/db.go | 1 + db/users.go | 60 +++++++++++++++-------- handler/auth.go | 16 ++++-- main.go | 18 ++++--- middleware/auth.go | 16 +++++- ui/src/client/models.ts | 3 ++ ui/src/i18n/langs/de-DE.json | 3 ++ ui/src/i18n/langs/en-US.json | 3 ++ ui/src/layouts/MainLayout.vue | 2 +- ui/src/pages/admin/UserManagementPage.vue | 31 +++++++++++- ui/src/stores/auth.ts | 4 ++ 11 files changed, 123 insertions(+), 34 deletions(-) diff --git a/db/db.go b/db/db.go index 5f0ff04..761f135 100644 --- a/db/db.go +++ b/db/db.go @@ -59,6 +59,7 @@ func (d *Database) migrate() error { surname TEXT, password_hash TEXT, auth_source TEXT NOT NULL DEFAULT 'local', + is_admin BOOLEAN NOT NULL DEFAULT FALSE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`, diff --git a/db/users.go b/db/users.go index 4f14f32..4f136bd 100644 --- a/db/users.go +++ b/db/users.go @@ -24,6 +24,7 @@ type User struct { Surname string `json:"surname,omitempty"` PasswordHash string `json:"-"` AuthSource string `json:"auth_source"` + IsAdmin bool `json:"is_admin"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -36,16 +37,16 @@ func HashPassword(password string) (string, error) { return string(hash), nil } -func (d *Database) CreateUser(username, email, displayName, password string) (*User, error) { +func (d *Database) CreateUser(username, email, displayName, password string, isAdmin bool) (*User, error) { hash, err := HashPassword(password) if err != nil { return nil, err } result, err := d.db.Exec( - `INSERT INTO users (username, email, display_name, password_hash, auth_source) - VALUES (?, ?, ?, ?, 'local')`, - username, email, displayName, hash, + `INSERT INTO users (username, email, display_name, password_hash, auth_source, is_admin) + VALUES (?, ?, ?, ?, 'local', ?)`, + username, email, displayName, hash, isAdmin, ) if err != nil { if isUniqueConstraintError(err) { @@ -63,11 +64,11 @@ func (d *Database) GetUserByID(id int64) (*User, error) { err := d.db.QueryRow( `SELECT id, username, COALESCE(email,''), COALESCE(display_name,''), COALESCE(given_name,''), COALESCE(surname,''), - COALESCE(password_hash,''), auth_source, created_at, updated_at + COALESCE(password_hash,''), auth_source, is_admin, created_at, updated_at FROM users WHERE id = ?`, id, ).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.GivenName, &user.Surname, &user.PasswordHash, - &user.AuthSource, &user.CreatedAt, &user.UpdatedAt) + &user.AuthSource, &user.IsAdmin, &user.CreatedAt, &user.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrUserNotFound @@ -83,11 +84,11 @@ func (d *Database) GetUserByUsername(username string) (*User, error) { err := d.db.QueryRow( `SELECT id, username, COALESCE(email,''), COALESCE(display_name,''), COALESCE(given_name,''), COALESCE(surname,''), - COALESCE(password_hash,''), auth_source, created_at, updated_at + COALESCE(password_hash,''), auth_source, is_admin, created_at, updated_at FROM users WHERE username = ?`, username, ).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.GivenName, &user.Surname, &user.PasswordHash, - &user.AuthSource, &user.CreatedAt, &user.UpdatedAt) + &user.AuthSource, &user.IsAdmin, &user.CreatedAt, &user.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrUserNotFound @@ -122,7 +123,7 @@ func (d *Database) ListUsers() ([]User, error) { rows, err := d.db.Query( `SELECT id, username, COALESCE(email,''), COALESCE(display_name,''), COALESCE(given_name,''), COALESCE(surname,''), - auth_source, created_at, updated_at + auth_source, is_admin, created_at, updated_at FROM users ORDER BY username`, ) if err != nil { @@ -134,7 +135,7 @@ func (d *Database) ListUsers() ([]User, error) { for rows.Next() { var u User if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.DisplayName, - &u.GivenName, &u.Surname, &u.AuthSource, &u.CreatedAt, &u.UpdatedAt); err != nil { + &u.GivenName, &u.Surname, &u.AuthSource, &u.IsAdmin, &u.CreatedAt, &u.UpdatedAt); err != nil { return nil, fmt.Errorf("failed to scan user: %w", err) } users = append(users, u) @@ -146,26 +147,43 @@ func (d *Database) ListUsers() ([]User, error) { return users, rows.Err() } -func (d *Database) UpdateUser(id int64, email, displayName *string, password *string) (*User, error) { +func (d *Database) UpdateUser(id int64, email, displayName *string, password *string, isAdmin *bool) (*User, error) { if password != nil { hash, err := HashPassword(*password) if err != nil { return nil, err } - _, err = d.db.Exec( - `UPDATE users SET email = COALESCE(?, email), display_name = COALESCE(?, display_name), - password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, - email, displayName, hash, id, - ) + if isAdmin != nil { + _, err = d.db.Exec( + `UPDATE users SET email = COALESCE(?, email), display_name = COALESCE(?, display_name), + password_hash = ?, is_admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + email, displayName, hash, *isAdmin, id, + ) + } else { + _, err = d.db.Exec( + `UPDATE users SET email = COALESCE(?, email), display_name = COALESCE(?, display_name), + password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + email, displayName, hash, id, + ) + } if err != nil { return nil, fmt.Errorf("failed to update user: %w", err) } } else { - _, err := d.db.Exec( - `UPDATE users SET email = COALESCE(?, email), display_name = COALESCE(?, display_name), - updated_at = CURRENT_TIMESTAMP WHERE id = ?`, - email, displayName, id, - ) + var err error + if isAdmin != nil { + _, err = d.db.Exec( + `UPDATE users SET email = COALESCE(?, email), display_name = COALESCE(?, display_name), + is_admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + email, displayName, *isAdmin, id, + ) + } else { + _, err = d.db.Exec( + `UPDATE users SET email = COALESCE(?, email), display_name = COALESCE(?, display_name), + updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + email, displayName, id, + ) + } if err != nil { return nil, fmt.Errorf("failed to update user: %w", err) } diff --git a/handler/auth.go b/handler/auth.go index 4bd0df6..cadd831 100644 --- a/handler/auth.go +++ b/handler/auth.go @@ -65,12 +65,14 @@ type createUserRequest struct { Password string `json:"password" binding:"required,min=8"` Email string `json:"email"` DisplayName string `json:"display_name"` + IsAdmin bool `json:"is_admin"` } type updateUserRequest struct { Email *string `json:"email"` DisplayName *string `json:"display_name"` Password *string `json:"password"` + IsAdmin *bool `json:"is_admin"` } func (h *AuthHandler) Login(c *gin.Context) { @@ -185,7 +187,7 @@ func (h *AuthHandler) CreateUser(c *gin.Context) { return } - user, err := h.database.CreateUser(req.Username, req.Email, req.DisplayName, req.Password) + user, err := h.database.CreateUser(req.Username, req.Email, req.DisplayName, req.Password, req.IsAdmin) if err != nil { if errors.Is(err, db.ErrUsernameExists) { c.AbortWithStatusJSON(http.StatusConflict, NewErrorResponse(err)) @@ -213,7 +215,15 @@ func (h *AuthHandler) UpdateUser(c *gin.Context) { return } - user, err := h.database.UpdateUser(id, req.Email, req.DisplayName, req.Password) + // Self-demote guard: prevent admin from removing their own admin flag + currentUserIDStr, _ := c.Get("user_id") + currentUserID, _ := strconv.ParseInt(currentUserIDStr.(string), 10, 64) + if id == currentUserID && req.IsAdmin != nil && !*req.IsAdmin { + c.AbortWithStatusJSON(http.StatusForbidden, NewErrorResponse(errors.New("cannot remove your own admin role"))) + return + } + + user, err := h.database.UpdateUser(id, req.Email, req.DisplayName, req.Password, req.IsAdmin) if err != nil { if errors.Is(err, db.ErrUserNotFound) { c.AbortWithStatusJSON(http.StatusNotFound, NewErrorResponse(err)) @@ -258,7 +268,7 @@ func (h *AuthHandler) DeleteUser(c *gin.Context) { func (h *AuthHandler) issueTokenPair(user *db.User) (*loginResponse, error) { accessToken, expiresAt, err := middleware.GenerateAccessToken( - user.ID, user.Username, user.Email, user.DisplayName, + user.ID, user.Username, user.Email, user.DisplayName, user.IsAdmin, h.config.Auth.JwtSecret, h.config.Auth.AccessTokenTTL, ) if err != nil { diff --git a/main.go b/main.go index b60633a..4fec49f 100644 --- a/main.go +++ b/main.go @@ -213,10 +213,16 @@ func main() { { auth.POST("logout", protectedAuthHandler.Logout) auth.GET("me", protectedAuthHandler.Me) - auth.GET("users", protectedAuthHandler.ListUsers) - auth.POST("users", protectedAuthHandler.CreateUser) - auth.PUT("users/:id", protectedAuthHandler.UpdateUser) - auth.DELETE("users/:id", protectedAuthHandler.DeleteUser) + + // Admin-only user management endpoints + admin := auth.Group("") + admin.Use(middleware.AdminRequiredMiddleware()) + { + admin.GET("users", protectedAuthHandler.ListUsers) + admin.POST("users", protectedAuthHandler.CreateUser) + admin.PUT("users/:id", protectedAuthHandler.UpdateUser) + admin.DELETE("users/:id", protectedAuthHandler.DeleteUser) + } } } } @@ -287,12 +293,12 @@ func runCreateAdmin(cfg *config.Config) { log.Fatal("Passwords do not match") } - user, err := database.CreateUser(username, email, displayName, password) + user, err := database.CreateUser(username, email, displayName, password, true) if err != nil { log.Fatalf("Failed to create user: %v", err) } - fmt.Printf("Admin user created: %s (id: %d)\n", user.Username, user.ID) + fmt.Printf("Admin user created: %s (id: %d, is_admin: true)\n", user.Username, user.ID) } func generateRandomSecret() string { diff --git a/middleware/auth.go b/middleware/auth.go index 1a3eccb..a894426 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -17,16 +17,18 @@ type UserClaims struct { Username string `json:"username"` Email string `json:"email,omitempty"` DisplayName string `json:"display_name,omitempty"` + IsAdmin bool `json:"is_admin"` jwt.RegisteredClaims } -func GenerateAccessToken(userID int64, username, email, displayName, secret string, ttlMinutes int) (string, int64, error) { +func GenerateAccessToken(userID int64, username, email, displayName string, isAdmin bool, secret string, ttlMinutes int) (string, int64, error) { expiresAt := time.Now().Add(time.Duration(ttlMinutes) * time.Minute) claims := UserClaims{ Username: username, Email: email, DisplayName: displayName, + IsAdmin: isAdmin, RegisteredClaims: jwt.RegisteredClaims{ Subject: strconv.FormatInt(userID, 10), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -65,6 +67,18 @@ func JWTAuthMiddleware(cfg *config.Config) gin.HandlerFunc { c.Set("user_id", claims.Subject) c.Set("username", claims.Username) + c.Set("is_admin", claims.IsAdmin) + c.Next() + } +} + +func AdminRequiredMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + isAdmin, exists := c.Get("is_admin") + if !exists || !isAdmin.(bool) { + c.AbortWithStatusJSON(http.StatusForbidden, errorResponse("admin access required")) + return + } c.Next() } } diff --git a/ui/src/client/models.ts b/ui/src/client/models.ts index 9d5846d..2a4a299 100644 --- a/ui/src/client/models.ts +++ b/ui/src/client/models.ts @@ -33,6 +33,7 @@ export interface UserProfile { given_name: string surname: string auth_source: string + is_admin: boolean created_at: string updated_at: string } @@ -42,10 +43,12 @@ export interface CreateUserRequest { password: string email?: string display_name?: string + is_admin?: boolean } export interface UpdateUserRequest { email?: string display_name?: string password?: string + is_admin?: boolean } diff --git a/ui/src/i18n/langs/de-DE.json b/ui/src/i18n/langs/de-DE.json index d51dbb6..53696ef 100644 --- a/ui/src/i18n/langs/de-DE.json +++ b/ui/src/i18n/langs/de-DE.json @@ -111,6 +111,9 @@ "NOTIFICATION_USER_UPDATED": "Benutzer erfolgreich aktualisiert", "NOTIFICATION_USER_DELETED": "Benutzer erfolgreich gelöscht", + "LABEL_ADMIN": "Admin", + "LABEL_CANNOT_DEMOTE_SELF": "Sie können Ihre eigene Admin-Rolle nicht entfernen", + "BTN_LOGIN_SSO": "Anmelden mit SSO", "LABEL_LOGIN_OR": "oder", "LABEL_SAML_USER_MANAGED_BY_IDP": "Dieser Benutzer wird vom Identity Provider verwaltet. Profildaten werden bei jeder Anmeldung automatisch aktualisiert." diff --git a/ui/src/i18n/langs/en-US.json b/ui/src/i18n/langs/en-US.json index 76fc67b..2f7fe34 100644 --- a/ui/src/i18n/langs/en-US.json +++ b/ui/src/i18n/langs/en-US.json @@ -111,6 +111,9 @@ "NOTIFICATION_USER_UPDATED": "User updated successfully", "NOTIFICATION_USER_DELETED": "User deleted successfully", + "LABEL_ADMIN": "Admin", + "LABEL_CANNOT_DEMOTE_SELF": "You cannot remove your own admin role", + "BTN_LOGIN_SSO": "Login with SSO", "LABEL_LOGIN_OR": "or", "LABEL_SAML_USER_MANAGED_BY_IDP": "This user is managed by the identity provider. Profile details are updated automatically on each login." diff --git a/ui/src/layouts/MainLayout.vue b/ui/src/layouts/MainLayout.vue index 0ab0468..bede1a6 100644 --- a/ui/src/layouts/MainLayout.vue +++ b/ui/src/layouts/MainLayout.vue @@ -136,7 +136,7 @@ - + diff --git a/ui/src/pages/admin/UserManagementPage.vue b/ui/src/pages/admin/UserManagementPage.vue index 41d4cd7..87c484b 100644 --- a/ui/src/pages/admin/UserManagementPage.vue +++ b/ui/src/pages/admin/UserManagementPage.vue @@ -19,6 +19,7 @@ const formEmail = ref(''); const formDisplayName = ref(''); const formPassword = ref(''); const formPasswordConfirm = ref(''); +const formIsAdmin = ref(false); const formError = ref(''); const formLoading = ref(false); @@ -73,6 +74,13 @@ const columns: QTableColumn[] = [ align: 'left', sortable: true, }, + { + name: 'is_admin', + field: 'is_admin', + label: t('LABEL_ADMIN'), + align: 'center', + sortable: true, + }, { name: 'created_at', field: 'created_at', @@ -105,6 +113,7 @@ function openCreateDialog() { formDisplayName.value = ''; formPassword.value = ''; formPasswordConfirm.value = ''; + formIsAdmin.value = false; formError.value = ''; showDialog.value = true; } @@ -116,6 +125,7 @@ function openEditDialog(user: UserProfile) { formDisplayName.value = user.display_name || ''; formPassword.value = ''; formPasswordConfirm.value = ''; + formIsAdmin.value = user.is_admin; formError.value = ''; showDialog.value = true; } @@ -147,9 +157,10 @@ async function onSubmit() { try { if (isEditing.value && editingUser.value) { - const data: { email?: string; display_name?: string; password?: string } = { + const data: { email?: string; display_name?: string; password?: string; is_admin?: boolean } = { email: formEmail.value, display_name: formDisplayName.value, + is_admin: formIsAdmin.value, }; if (formPassword.value) { data.password = formPassword.value; @@ -157,9 +168,10 @@ async function onSubmit() { await Backend.updateUser(editingUser.value.id, data); Notify.create({ message: t('NOTIFICATION_USER_UPDATED'), color: 'positive' }); } else { - const createData: { username: string; password: string; email?: string; display_name?: string } = { + const createData: { username: string; password: string; email?: string; display_name?: string; is_admin?: boolean } = { username: formUsername.value.trim(), password: formPassword.value, + is_admin: formIsAdmin.value, }; if (formEmail.value) createData.email = formEmail.value; if (formDisplayName.value) createData.display_name = formDisplayName.value; @@ -238,6 +250,9 @@ onMounted(() => { +

{{ col.value }}
@@ -289,6 +304,18 @@ onMounted(() => { :disable="isSamlUser" /> + + + + {{ $t('LABEL_CANNOT_DEMOTE_SELF') }} + + ``` -### User Management UI — SAML users are read-only +### User Management UI — SAML user profiles are IdP-managed The IdP is the source of truth for SAML user profiles. When editing a SAML user (`auth_source = 'saml'`) in the User Management page: - **Email** and **Display Name** fields are disabled (managed by IdP) - **Password** fields are hidden (SAML users have no local password) -- **Save** button is hidden (no editable fields) +- **Admin toggle** is editable (admin role is managed locally, not by the IdP) +- **Save** button is visible (to allow admin toggle changes) - An info banner explains that the profile is managed by the identity provider and updated on each login No other frontend files need changes — the auth store, axios interceptors, router guard, and token refresh logic from ADR-001 all work unchanged for SAML-authenticated users. @@ -377,8 +378,10 @@ auth: ## Security Considerations - **Assertion validation**: `crewjam/saml` validates signature, issuer, audience restriction, `NotBefore`/`NotOnOrAfter`, and `InResponseTo` (replay prevention) by default — do not bypass these checks +- **IDP-initiated SSO disabled**: `AllowIDPInitiated` is set to `false`. All SAML logins must originate from the "Login with SSO" button (SP-initiated). This prevents assertion replay attacks where a captured SAMLResponse is POSTed directly to the ACS endpoint. +- **SAML cookie Secure flag**: The `saml_request_id` cookie (used for `InResponseTo` validation) is set with `Secure: true` and `HttpOnly: true`, ensuring it is only transmitted over HTTPS. - **ACS URL must be HTTPS**: EntraID and ADFS reject HTTP ACS URLs; document this as a hard requirement -- **Token query params**: `?token=` and `?refresh=` are short-lived (access token TTL) and cleared from the URL immediately by the SPA. They will appear in browser history — a known trade-off of this approach +- **Token handoff via hash fragment**: The redirect URL uses a hash fragment (`/ui/?#/login?token=...&refresh=...`) so tokens are not sent to the server in logs, proxies, or Referer headers. They will appear in browser history — a known trade-off of this approach. - **RelayState**: Managed by `crewjam/saml` to prevent CSRF on the ACS endpoint - **SAML + local coexistence**: Operators can have both enabled simultaneously. A "break-glass" local admin account is recommended so access is not lost if the IdP is unreachable - **IdP metadata refresh**: Re-fetch metadata hourly to automatically handle IdP signing certificate rotations without a restart diff --git a/docs/adr/ADR-003-user-management-ui.md b/docs/adr/ADR-003-user-management-ui.md index 5c19cde..a2c2269 100644 --- a/docs/adr/ADR-003-user-management-ui.md +++ b/docs/adr/ADR-003-user-management-ui.md @@ -66,7 +66,7 @@ On 409: show "Username already exists" error. Same q-dialog, pre-populated with existing values. Username is read-only (not editable after creation). Password fields are optional — leave blank to keep current password. Admin toggle available — but disabled when editing your own account (self-demote guard to prevent lockout). -For SAML users (`auth_source = 'saml'`): email, display name, and password fields are disabled (managed by IdP). Only the admin toggle is editable. +For SAML users (`auth_source = 'saml'`): email, display name, and password fields are disabled (managed by IdP). Only the admin toggle is editable. The save button remains visible so admin role changes can be saved. On submit: `Backend.updateUser(id, data)` → success notification → reload table. diff --git a/docs/adr/ADR-004-cors-configuration.md b/docs/adr/ADR-004-cors-configuration.md index 12c5f25..24b6246 100644 --- a/docs/adr/ADR-004-cors-configuration.md +++ b/docs/adr/ADR-004-cors-configuration.md @@ -1,6 +1,6 @@ # ADR-004: Configurable CORS Policy -**Status:** Proposed +**Status:** Implemented **Date:** 2026-04-04 **Deciders:** OpenVox View maintainers **Depends on:** ADR-001 (authentication, JWT Bearer tokens) @@ -139,8 +139,8 @@ Key changes: ## Implementation Checklist -- [ ] Add `CorsOrigin` field to `Config` struct in `config/config.go` -- [ ] Add Viper default and `OPENVOXVIEW_CORS_ORIGIN` env binding -- [ ] Replace `AllowCORS` function with `CORSMiddleware` in `main.go` -- [ ] Update `CONFIGURATION.md` with `cors_origin` setting -- [ ] Build and verify (`go vet`, `go build`) +- [x] Add `CorsOrigin` field to `Config` struct in `config/config.go` +- [x] Add Viper default and `OPENVOXVIEW_CORS_ORIGIN` env binding +- [x] Replace `AllowCORS` function with `CORSMiddleware` in `main.go` +- [x] Update `CONFIGURATION.md` with `cors_origin` setting +- [x] Build and verify (`go vet`, `go build`)