From b313c126ce9874ff6677b6ca6347430bcc077e98 Mon Sep 17 00:00:00 2001 From: Kacper Date: Thu, 10 Sep 2026 09:13:04 +0200 Subject: [PATCH] ADconsole v1.1.0 Config backup, a Docker dev/prod split, and a Gitea-built release image. Added - /settings -> General -> "Configuration backup": JSON export/import of the panel's general settings + attribute definitions (App\Services\ConfigBackupService); audited as config.export / config.import. Export can leave out the attribute-encryption key. No AD data included. - docker/compose.dev.yaml: live-reload dev stack that bind-mounts the working tree. docker/php/Dockerfile is now multi-stage (base -> dev / prod). - .github/workflows/release.yml: on every vX.Y.Z tag, builds the prod image and pushes gitea.kzbikowski.pl/kzbkowski/adconsole:{X.Y.Z,X.Y,latest}. Changed - docker/compose.yaml: self-contained prod image (app code + composer install --no-dev baked in via COPY); persistent state in docker/data/{app_storage,db}; defaults to the released image (ADCONSOLE_VERSION pins a tag, ADCONSOLE_IMAGE replaces the ref). Apache now honours public/.htaccess (AllowOverride All). Run from the repo root with --env-file .env. - Repo root now holds only README.md; CHANGELOG / CONTRIBUTING / SECURITY / CODE_OF_CONDUCT moved under docs/. Upgrade notes: docs/upgrading.md#v110--docker-layout. Full log: docs/CHANGELOG.md. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JKT9p3bfP8RZesExdYCS52 --- .dockerignore | 18 +- .env.example | 10 +- .github/ISSUE_TEMPLATE/config.yml | 4 +- .github/pull_request_template.md | 2 +- .github/workflows/release.yml | 63 ++++++ .gitignore | 4 + CLAUDE/AGENTS.md | 2 +- CLAUDE/README.md | 4 +- README.md | 34 +++- app/Exceptions/ConfigBackupException.php | 12 ++ .../Controllers/Admin/ConfigController.php | 63 ++++++ app/Services/ConfigBackupService.php | 188 ++++++++++++++++++ docker/compose.dev.override.yaml.template | 23 +++ docker/compose.dev.yaml | 94 +++++++++ docker/compose.override.yaml.template | 14 +- docker/compose.yaml | 62 ++++-- docker/php/Dockerfile | 93 +++++++-- docker/php/entrypoint.sh | 39 +++- CHANGELOG.md => docs/CHANGELOG.md | 42 +++- CODE_OF_CONDUCT.md => docs/CODE_OF_CONDUCT.md | 0 CONTRIBUTING.md => docs/CONTRIBUTING.md | 10 +- docs/README.md | 6 +- SECURITY.md => docs/SECURITY.md | 4 +- docs/configuration.md | 18 +- docs/deployment.md | 119 ++++++++--- docs/development.md | 33 +++ docs/publishing.md | 22 +- docs/upgrading.md | 44 +++- lang/en/audit.php | 2 + lang/en/config.php | 17 ++ lang/pl/audit.php | 2 + lang/pl/config.php | 17 ++ resources/views/admin/config/index.blade.php | 49 +++++ routes/web.php | 4 + tests/Feature/ConfigBackupTest.php | 175 ++++++++++++++++ 35 files changed, 1174 insertions(+), 119 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 app/Exceptions/ConfigBackupException.php create mode 100644 app/Services/ConfigBackupService.php create mode 100644 docker/compose.dev.override.yaml.template create mode 100644 docker/compose.dev.yaml rename CHANGELOG.md => docs/CHANGELOG.md (73%) rename CODE_OF_CONDUCT.md => docs/CODE_OF_CONDUCT.md (100%) rename CONTRIBUTING.md => docs/CONTRIBUTING.md (87%) rename SECURITY.md => docs/SECURITY.md (93%) create mode 100644 tests/Feature/ConfigBackupTest.php diff --git a/.dockerignore b/.dockerignore index f1010a4..a8bcf04 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,9 @@ -# Build-context trimming for `docker build` (docker/php/Dockerfile). -# The compose stack bind-mounts the project at runtime, so the image only needs -# the entrypoint; keep the context small. +# Build context for docker/php/Dockerfile (context = repo root). +# +# prod target — COPYs the application in, so keep app code but drop +# everything the image rebuilds or must never ship (vendor/, +# node_modules/, .env, tests/, docs/, host state in ./data). +# dev target — bakes nothing; compose.dev.yaml bind-mounts the repo. .git .github @@ -37,5 +40,12 @@ phpunit.xml .phpunit.cache *.md +.dockerignore + +# Compose files + local overrides + persistent state — never needed in the image. +docker/compose.yaml +docker/compose.dev.yaml docker/compose.override.yaml -docker/compose.override.yml +docker/compose.dev.override.yaml +docker/*.template +docker/data diff --git a/.env.example b/.env.example index 346f9ea..c1bc050 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,13 @@ APP_URL=https://adconsole.example.com # bare-metal install. APP_DOMAIN=adconsole.example.com +# Docker image for docker/compose.yaml. By default it pulls the release the +# Gitea workflow published; ADCONSOLE_VERSION pins a tag, ADCONSOLE_IMAGE +# replaces the whole reference (e.g. a private mirror). Ignored on bare metal +# and by `docker compose ... up --build` (which rebuilds locally). +# ADCONSOLE_VERSION=1.1.0 +# ADCONSOLE_IMAGE=gitea.kzbikowski.pl/kzbkowski/adconsole:1.1.0 + # Interface language: pl | en. FALLBACK is used for missing translation keys. APP_LOCALE=pl APP_FALLBACK_LOCALE=en @@ -48,7 +55,8 @@ DB_USERNAME=adconsole DB_PASSWORD= # Only read by docker/compose.yaml when it first initialises the MariaDB -# volume. Changing them later does NOT change an existing database. +# data dir (docker/data/db). Changing them later does NOT change an existing +# database. DB_ROOT_PASSWORD= # --- Session / cache / queue -------------------------------------------- diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 8be1483..3f1cf78 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,8 +2,8 @@ blank_issues_enabled: false contact_links: - name: Security vulnerability - url: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/src/branch/main/SECURITY.md - about: Do not file security issues publicly — email the maintainer (see SECURITY.md). + url: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/src/branch/main/docs/SECURITY.md + about: Do not file security issues publicly — email the maintainer (see docs/SECURITY.md). - name: Question / help url: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/issues about: Deployment and configuration questions — search existing issues first. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index bc72783..d2e8888 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,7 +10,7 @@ - [ ] **Database migration** included - [ ] New **`.env` key** — added to `.env.example` and `docs/configuration.md` - [ ] New user-facing text — added to **both** `lang/pl` and `lang/en` -- [ ] `CHANGELOG.md` updated (Unreleased) +- [ ] `docs/CHANGELOG.md` updated (Unreleased) ## Checks diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0291e01 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,63 @@ +name: Release image + +# Builds the self-contained production image (docker/php/Dockerfile, target +# `prod`) and pushes it to this Gitea instance's container registry whenever a +# vX.Y.Z tag is pushed. `workflow_dispatch` builds an untagged snapshot. +# +# gitea.kzbikowski.pl/kzbkowski/adconsole:1.1.0 +# gitea.kzbikowski.pl/kzbkowski/adconsole:1.1 +# gitea.kzbikowski.pl/kzbkowski/adconsole:latest +# +# Needs `packages: write`. The built-in GITHUB_TOKEN works for the repo owner's +# package namespace; set a `REGISTRY_TOKEN` secret if the instance restricts it. + +on: + push: + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: read + packages: write + +env: + REGISTRY: gitea.kzbikowski.pl + IMAGE_NAME: ${{ github.repository_owner }}/adconsole + +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ github.ref_type == 'tag' }} + type=sha,enable=${{ github.ref_type != 'tag' }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: docker/php/Dockerfile + target: prod + push: true + provenance: false + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index f7e87be..cb68902 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,10 @@ Thumbs.db # Local Docker overrides (ports, extra services) - not for distribution. /docker/compose.override.yaml /docker/compose.override.yml +/docker/compose.dev.override.yaml + +# Persistent state for the production stack (docker/compose.yaml): DB + storage. +/docker/data/ # AI assistant / editor local state. /.claude diff --git a/CLAUDE/AGENTS.md b/CLAUDE/AGENTS.md index 923aac8..74c48d1 100644 --- a/CLAUDE/AGENTS.md +++ b/CLAUDE/AGENTS.md @@ -38,7 +38,7 @@ dashboard and an org chart. PHP 8.2+. Interface is PL/EN. | `app/Http/Controllers/Admin/*` | one controller per module (Users, Computers, Groups, Gpo, OrgChart, Dashboard, Audit, Config) | | `app/Ldap/User.php` | AD user model + attribute map + `toPanelProfile()` | | `app/Ldap/Rules/MemberOfLoginGroup.php` | login authorisation rule | -| `app/Services/` | `PanelSettingsService` (config, DB-backed), `AttributeCipher`, `LdapAttributeHelper` (UAC bits, FileTime), `OuTreeService` (cached OU tree), `AuditLogger` (writes `panel_audit`; never throws — a log failure is only `report()`ed) | +| `app/Services/` | `PanelSettingsService` (config, DB-backed), `ConfigBackupService` (JSON export/import of the panel config), `AttributeCipher`, `LdapAttributeHelper` (UAC bits, FileTime), `OuTreeService` (cached OU tree), `AuditLogger` (writes `panel_audit`; never throws — a log failure is only `report()`ed) | | `app/Models/PanelSetting.php` | key→JSON row: general panel config (`panel_settings`) | | `app/Models/PanelAttribute.php` | one row per configured AD attribute (`panel_attributes`, ordered by `position`) | | `app/Models/AuditLog.php` | one row per audited panel action (`panel_audit`); written only via `AuditLogger`, read only by `AuditController` | diff --git a/CLAUDE/README.md b/CLAUDE/README.md index 8c77f44..39f8586 100644 --- a/CLAUDE/README.md +++ b/CLAUDE/README.md @@ -13,10 +13,10 @@ from the repository root. Human-facing documentation (installation, configuration, deployment, troubleshooting, …) lives in [`../docs/`](../docs/). Contribution rules are in -[`../CONTRIBUTING.md`](../CONTRIBUTING.md). +[`../docs/CONTRIBUTING.md`](../docs/CONTRIBUTING.md). ## Keeping it current Update `AGENTS.md` whenever you change the architecture, the golden rules, the top-level layout, or the commands in the dev loop. If you rename or move it, -update the `@`-import in `../CLAUDE.md` and the links in `../CONTRIBUTING.md`. +update the `@`-import in `../CLAUDE.md` and the links in `../docs/CONTRIBUTING.md`. diff --git a/README.md b/README.md index 5601fee..825700a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ is no database copy of your directory to drift out of sync. PHP 8.2+ host. > ⚠️ ADconsole performs privileged directory operations (password resets, -> account enable/disable, deletions). Read [`SECURITY.md`](SECURITY.md) and the +> account enable/disable, deletions). Read [`SECURITY.md`](docs/SECURITY.md) and the > [hardening checklist](docs/deployment.md#hardening-checklist) before exposing it. --- @@ -64,17 +64,23 @@ git clone https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git adconsole && cd adc cp .env.example .env # edit .env: APP_URL/APP_DOMAIN, DB_* passwords, and the whole LDAP_* / AD_* block -# With an external Traefik proxy (see docker/compose.yaml): -docker compose -f docker/compose.yaml up -d --build +# Production — pulls the released image, external Traefik proxy. Run from the +# repo root and point Compose at the repo-root .env: +docker compose --env-file .env -f docker/compose.yaml up -d # Or without a proxy — publish a local port instead: cp docker/compose.override.yaml.template docker/compose.override.yaml -docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build +docker compose --env-file .env -f docker/compose.yaml -f docker/compose.override.yaml up -d # -> http://localhost:8080 (set APP_URL to match) + +# Live-reload dev stack (bind-mounts the working tree): +docker compose --env-file .env -f docker/compose.dev.yaml up --build ``` -The container entrypoint installs dependencies, generates `APP_KEY`, runs -migrations and warms the caches on first boot. Details and production notes: +`docker/compose.yaml` runs the self-contained image the Gitea release workflow +publishes (`…/adconsole:1.1.0`); `up -d` pulls it, `--build` rebuilds from your +tree. Only `.env` and `docker/data/` (DB + storage) are mounted. The entrypoint +generates `APP_KEY`, runs migrations and warms the caches on first boot. Details: [`docs/deployment.md`](docs/deployment.md). ## Quick start — bare metal @@ -148,7 +154,9 @@ public/js/main.js hand-written JS routes/web.php English URL segments and route names lang/{pl,en}/ structured translation keys docs/ installation / AD / deployment / architecture / … -docker/ compose stack + PHP image +docker/compose.yaml production stack (self-contained image + MariaDB) +docker/compose.dev.yaml live-reload dev stack (bind-mounts the working tree) +docker/php/ PHP 8.4 + Apache Dockerfile (base/dev/prod) + entrypoint ``` ## Documentation @@ -164,6 +172,10 @@ Everything lives in [`docs/`](docs/): [upgrading](docs/upgrading.md) · [publishing](docs/publishing.md). +Project meta: [changelog](docs/CHANGELOG.md) · +[contributing](docs/CONTRIBUTING.md) · +[security policy](docs/SECURITY.md). + ## Roadmap / non-goals Planned: server-side password-policy validation before write; optional TOTP for @@ -193,10 +205,10 @@ When you deploy ADconsole your organisation is the **data controller** for the personal data it reads from AD (names, logins, e-mail, phone, photo, …). You are responsible for the lawful basis, retention and data-subject rights; restrict panel access to authorised admins and always use LDAPS/STARTTLS. See the -in-app **About → Legal** page and [`SECURITY.md`](SECURITY.md). +in-app **About → Legal** page and [`SECURITY.md`](docs/SECURITY.md). ## Contributing -Issues and PRs welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md) and the -[`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md). Security reports: -[`SECURITY.md`](SECURITY.md). +Issues and PRs welcome — see [`CONTRIBUTING.md`](docs/CONTRIBUTING.md) and the +[`CODE_OF_CONDUCT.md`](docs/CODE_OF_CONDUCT.md). Security reports: +[`SECURITY.md`](docs/SECURITY.md). diff --git a/app/Exceptions/ConfigBackupException.php b/app/Exceptions/ConfigBackupException.php new file mode 100644 index 0000000..a9cc5c7 --- /dev/null +++ b/app/Exceptions/ConfigBackupException.php @@ -0,0 +1,12 @@ +with('status', __('config.flash.attributes_saved')) ->withFragment('attributes'); } + + /** + * Pobranie kopii zapasowej konfiguracji panelu jako plik JSON: ustawienia + * ogólne + definicje atrybutów (bez danych z AD). Parametr `exclude_key=1` + * pomija w pliku klucz szyfrujący atrybuty. + */ + public function export(Request $request, ConfigBackupService $backup): JsonResponse + { + $includeKey = ! $request->boolean('exclude_key'); + + $payload = $backup->export($includeKey); + + $this->audit->log('config.export', [ + 'context' => [ + 'attributes' => count($payload['attributes']), + 'encryption_key_included' => $payload['encryption_key_included'], + ], + ]); + + $filename = 'adconsole-config-'.now()->format('Ymd-His').'.json'; + + return response()->json($payload, 200, [ + 'Content-Disposition' => 'attachment; filename="'.$filename.'"', + 'Cache-Control' => 'no-store', + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + /** + * Wczytanie kopii zapasowej z pliku JSON. Nadpisuje ustawienia ogólne i/lub + * listę atrybutów zawartością pliku; sekcje nieobecne w pliku zostają bez + * zmian. Błąd formatu wraca jako błąd walidacji pola `config_file`. + */ + public function import(Request $request, ConfigBackupService $backup): RedirectResponse + { + $request->validate([ + 'config_file' => ['required', 'file', 'max:1024', 'mimes:json,txt'], + ]); + + $raw = (string) file_get_contents($request->file('config_file')->getRealPath()); + $data = json_decode($raw, true); + + try { + if (json_last_error() !== JSON_ERROR_NONE) { + throw new ConfigBackupException(__('config.backup.error_not_json')); + } + + $result = $backup->import($data); + } catch (ConfigBackupException $e) { + return back()->withErrors(['config_file' => $e->getMessage()]); + } + + $this->audit->log('config.import', [ + 'context' => [ + 'settings' => $result['settings'], + 'attributes' => $result['attributes'], + ], + ]); + + return back()->with('status', __('config.backup.flash_imported')); + } } diff --git a/app/Services/ConfigBackupService.php b/app/Services/ConfigBackupService.php new file mode 100644 index 0000000..57047ea --- /dev/null +++ b/app/Services/ConfigBackupService.php @@ -0,0 +1,188 @@ + + */ + public function export(bool $includeEncryptionKey = true): array + { + $general = $this->settings->all(); + + if (! $includeEncryptionKey) { + $general['encryption']['key'] = ''; + } + + $keyIncluded = ($general['encryption']['key'] ?? '') !== ''; + + return [ + 'format' => self::FORMAT, + 'version' => self::SCHEMA_VERSION, + 'generator' => 'ADconsole', + 'exported_at' => now()->toIso8601String(), + 'encryption_key_included' => $keyIncluded, + 'settings' => $general, + 'attributes' => array_map( + static fn (array $a): array => [ + 'key' => $a['key'], + 'label' => $a['label'], + 'ldap' => $a['ldap'], + 'category' => $a['category'], + 'type' => $a['type'], + 'regex' => $a['regex'], + 'required' => $a['required'], + 'help' => $a['help'], + 'column' => $a['column'], + ], + $this->settings->attributes(), + ), + ]; + } + + /** + * Waliduje i wgrywa kopię zapasową. Nadpisuje ustawienia ogólne i/lub + * listę atrybutów tym, co jest w pliku; sekcje nieobecne w pliku zostają + * bez zmian. + * + * @param mixed $data zdekodowana zawartość pliku (oczekiwana: tablica) + * @return array{settings: bool, attributes: int} co zostało wgrane + * + * @throws ConfigBackupException gdy plik jest pusty, w złym formacie albo + * pochodzi z nowszej wersji panelu + */ + public function import(mixed $data): array + { + if (! is_array($data)) { + throw new ConfigBackupException(__('config.backup.error_not_json')); + } + + if (($data['format'] ?? null) !== self::FORMAT) { + throw new ConfigBackupException(__('config.backup.error_bad_format')); + } + + $version = (int) ($data['version'] ?? 0); + if ($version < 1 || $version > self::SCHEMA_VERSION) { + throw new ConfigBackupException(__('config.backup.error_bad_version')); + } + + $hasSettings = isset($data['settings']) && is_array($data['settings']); + $hasAttributes = isset($data['attributes']) && is_array($data['attributes']); + + if (! $hasSettings && ! $hasAttributes) { + throw new ConfigBackupException(__('config.backup.error_empty')); + } + + $applied = ['settings' => false, 'attributes' => 0]; + + if ($hasSettings) { + $this->settings->save($this->sanitizeSettings($data['settings'])); + $applied['settings'] = true; + } + + if ($hasAttributes) { + // saveAttributes() sam normalizuje wiersze, wymusza reguły pozycji + // systemowych / wyliczanych i odtwarza te, których w pliku brak. + $this->settings->saveAttributes($this->sanitizeAttributes($data['attributes'])); + $applied['attributes'] = count($this->settings->attributes()); + } + + return $applied; + } + + /** + * Sprowadza dowolne wejście do kształtu ustawień ogólnych: start od + * wbudowanych domyślnych, nadpisanie wyłącznie znanych kluczy, z kontrolą + * typu i zakresu. Odpowiada regułom z ConfigController::update(), żeby plik + * z ręcznie podmienioną wartością nie wprowadził panelu w zły stan. + * + * @param array $in + * @return array + */ + protected function sanitizeSettings(array $in): array + { + $d = $this->settings->defaults(); + + $reset = is_array($in['reset_password'] ?? null) ? $in['reset_password'] : []; + $policy = is_array($in['password_policy'] ?? null) ? $in['password_policy'] : []; + $enc = is_array($in['encryption'] ?? null) ? $in['encryption'] : []; + $audit = is_array($in['audit'] ?? null) ? $in['audit'] : []; + + return [ + 'reset_password' => [ + 'must_change_password' => filter_var($reset['must_change_password'] ?? $d['reset_password']['must_change_password'], FILTER_VALIDATE_BOOL), + 'unlock_account' => filter_var($reset['unlock_account'] ?? $d['reset_password']['unlock_account'], FILTER_VALIDATE_BOOL), + ], + 'password_policy' => [ + 'max_age_days' => max(0, min(3650, (int) ($policy['max_age_days'] ?? $d['password_policy']['max_age_days']))), + 'generator_length' => max(8, min(128, (int) ($policy['generator_length'] ?? $d['password_policy']['generator_length']))), + 'generator_symbols' => filter_var($policy['generator_symbols'] ?? $d['password_policy']['generator_symbols'], FILTER_VALIDATE_BOOL), + ], + 'encryption' => [ + 'key' => trim((string) ($enc['key'] ?? '')), + ], + 'audit' => [ + 'require_reason' => filter_var($audit['require_reason'] ?? $d['audit']['require_reason'], FILTER_VALIDATE_BOOL), + ], + ]; + } + + /** + * Sprowadza listę atrybutów z pliku do wierszy przyjmowanych przez + * PanelSettingsService::saveAttributes() (ta klasa dokończy normalizację). + * Wiersze bez klucza są pomijane. + * + * @param array $in + * @return list> + */ + protected function sanitizeAttributes(array $in): array + { + $rows = []; + + foreach ($in as $row) { + if (! is_array($row) || (string) ($row['key'] ?? '') === '') { + continue; + } + + $rows[] = [ + 'key' => (string) ($row['key'] ?? ''), + 'label' => (string) ($row['label'] ?? ''), + 'ldap' => (string) ($row['ldap'] ?? ''), + 'category' => (string) ($row['category'] ?? 'general'), + 'type' => (string) ($row['type'] ?? 'text'), + 'regex' => (string) ($row['regex'] ?? ''), + 'required' => filter_var($row['required'] ?? false, FILTER_VALIDATE_BOOL), + 'help' => (string) ($row['help'] ?? ''), + 'column' => filter_var($row['column'] ?? false, FILTER_VALIDATE_BOOL), + ]; + } + + return $rows; + } +} diff --git a/docker/compose.dev.override.yaml.template b/docker/compose.dev.override.yaml.template new file mode 100644 index 0000000..94499d7 --- /dev/null +++ b/docker/compose.dev.override.yaml.template @@ -0,0 +1,23 @@ +# ============================================================================= +# Local / no-Traefik override for the development stack (docker/compose.dev.yaml). +# +# cp docker/compose.dev.override.yaml.template docker/compose.dev.override.yaml +# docker compose --env-file .env \ +# -f docker/compose.dev.yaml -f docker/compose.dev.override.yaml up --build +# +# The app is published on http://localhost:8080 and the external +# `traefik_public` network becomes a normal local one. Set +# APP_URL=http://localhost:8080 in .env. +# ============================================================================= + +services: + app: + ports: + - "8080:80" + networks: + - internal + labels: [] + +networks: + traefik_public: + external: false diff --git a/docker/compose.dev.yaml b/docker/compose.dev.yaml new file mode 100644 index 0000000..38d6bff --- /dev/null +++ b/docker/compose.dev.yaml @@ -0,0 +1,94 @@ +name: adconsole-dev + +# ============================================================================= +# Development stack — live-reload. The working tree is bind-mounted over +# /var/www/html, so edits to PHP / Blade / assets take effect on the next +# request (opcache revalidates every hit in the `dev` image target). The +# entrypoint runs `composer install` (incl. dev deps) and `key:generate` on +# the first boot of a fresh clone. +# +# Persistent state is kept in named volumes (not docker/data), so `migrate:fresh` +# and `down -v` behave like a throwaway dev box: +# +# app_storage -> /var/www/html/storage +# db_data -> /var/lib/mysql +# +# Run from the repo root: +# cp .env.example .env # APP_ENV=local, APP_DEBUG=true, real LDAP_* +# docker compose --env-file .env -f docker/compose.dev.yaml up --build +# +# Reverse proxy: an external Traefik on `traefik_public`, same as production. +# Without Traefik, uncomment the `ports:` block below and open +# http://localhost:8080 (set APP_URL to match). +# ============================================================================= + +services: + app: + build: + context: .. + dockerfile: docker/php/Dockerfile + target: dev + container_name: adconsole_dev_app + restart: unless-stopped + environment: + # Reach the DB service by name regardless of DB_HOST in the bind-mounted + # .env (Laravel's dotenv does not override a real env var). Kept minimal so + # `artisan test` inside the container still picks up phpunit.xml's env. + DB_HOST: mariadb + volumes: + - ..:/var/www/html + - app_storage:/var/www/html/storage + # ports: + # - "8080:80" + depends_on: + mariadb: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost/up"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 120s + networks: + - internal + - traefik_public + labels: + - traefik.enable=true + - traefik.docker.network=traefik_public + - traefik.http.routers.adconsole-dev.rule=Host(`${APP_DOMAIN:-adconsole.localhost}`) + - traefik.http.routers.adconsole-dev.entrypoints=websecure + - traefik.http.routers.adconsole-dev.tls=true + - traefik.http.services.adconsole-dev.loadbalancer.server.port=80 + + mariadb: + image: mariadb:11 + container_name: adconsole_dev_db + restart: unless-stopped + environment: + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-change-me-root} + MARIADB_DATABASE: ${DB_DATABASE:-adconsole} + MARIADB_USER: ${DB_USERNAME:-adconsole} + MARIADB_PASSWORD: ${DB_PASSWORD:-change-me} + MARIADB_AUTO_UPGRADE: "1" + volumes: + - db_data:/var/lib/mysql + # Expose the DB on the host for a GUI client. + ports: + - "3306:3306" + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + networks: + - internal + +volumes: + app_storage: + db_data: + +networks: + internal: + traefik_public: + external: true diff --git a/docker/compose.override.yaml.template b/docker/compose.override.yaml.template index 7af241a..7f2d5d2 100644 --- a/docker/compose.override.yaml.template +++ b/docker/compose.override.yaml.template @@ -1,14 +1,16 @@ # ============================================================================= -# Local / no-Traefik override. +# Local / no-Traefik override for the production stack (docker/compose.yaml). # -# Copy to docker/compose.override.yaml (git-ignored) to run the stack without -# a reverse proxy - the app is published straight on a host port and the -# external `traefik_public` network is neutralised. +# Copy it, then pass both files (the override is NOT auto-merged because it +# lives under docker/, not the project root): # # cp docker/compose.override.yaml.template docker/compose.override.yaml -# docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build +# docker compose --env-file .env \ +# -f docker/compose.yaml -f docker/compose.override.yaml up -d --build # -# Then open http://localhost:8080 (set APP_URL=http://localhost:8080 in .env). +# The app is published on a host port and the external `traefik_public` +# network becomes a normal local one. Open http://localhost:8080 +# (set APP_URL=http://localhost:8080 in .env). # ============================================================================= services: diff --git a/docker/compose.yaml b/docker/compose.yaml index 0bb246b..c7c7faf 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -1,30 +1,64 @@ name: adconsole # ============================================================================= -# Production-ish Compose stack: PHP/Apache app + MariaDB. +# Production stack — a self-contained PHP/Apache image + MariaDB, behind an +# external Traefik proxy on the `traefik_public` network. # -# Reverse proxy: this file assumes an external Traefik instance on the -# `traefik_public` network. If you don't use Traefik, drop in a local override -# that publishes a port instead - see docker/compose.override.yaml.template -# and docs/deployment.md. +# By default this runs the image the Gitea release workflow publishes +# (built from docker/php/Dockerfile, target `prod` — code + `composer install +# --no-dev` baked in). `up -d` pulls it; `up -d --build` rebuilds from the +# working tree instead. Nothing from the tree is mounted except `.env`. +# Persistent state lives next to this file: # -# Run from the repo root: -# docker compose -f docker/compose.yaml up -d --build +# docker/data/app_storage -> /var/www/html/storage (logs, framework cache) +# docker/data/db -> /var/lib/mysql (database — BACK UP) +# +# Run FROM THE REPO ROOT and point Compose at the repo-root .env for +# ${...} interpolation: +# +# cp .env.example .env # then fill it in +# docker compose --env-file .env -f docker/compose.yaml up -d +# docker compose --env-file .env -f docker/compose.yaml logs -f app +# +# No Traefik? Add the local port-publishing override: +# cp docker/compose.override.yaml.template docker/compose.override.yaml +# docker compose --env-file .env \ +# -f docker/compose.yaml -f docker/compose.override.yaml up -d +# # -> http://localhost:8080 (set APP_URL to match) # ============================================================================= services: app: + # Default: the image the Gitea release workflow (.github/workflows/release.yml) + # publishes for the current release. `docker compose ... pull` fetches it; + # `... up -d --build` rebuilds it locally under the same name instead. Pin a + # different build with ADCONSOLE_VERSION, or override the whole ref with + # ADCONSOLE_IMAGE. + image: ${ADCONSOLE_IMAGE:-gitea.kzbikowski.pl/kzbkowski/adconsole:${ADCONSOLE_VERSION:-1.1.0}} build: context: .. dockerfile: docker/php/Dockerfile + target: prod container_name: adconsole_app restart: unless-stopped + environment: + # Reach the DB service by name — overrides the dev-oriented DB_HOST in the + # bind-mounted .env (Laravel's dotenv does not override a real env var). + DB_HOST: mariadb volumes: - - ..:/var/www/html - - app_storage:/var/www/html/storage + # `.env` is git-ignored and holds the service-account password; it is + # provided at runtime, not baked into the image. Laravel reads it directly. + - ../.env:/var/www/html/.env + - ./data/app_storage:/var/www/html/storage depends_on: mariadb: condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost/up"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 60s networks: - internal - traefik_public @@ -49,7 +83,7 @@ services: MARIADB_PASSWORD: ${DB_PASSWORD:-change-me} MARIADB_AUTO_UPGRADE: "1" volumes: - - db_data:/var/lib/mysql + - ./data/db:/var/lib/mysql healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 10s @@ -59,13 +93,9 @@ services: networks: - internal -volumes: - db_data: - app_storage: - networks: internal: - # Provided by your Traefik deployment. Remove this (and the `traefik_public` - # entry + labels under `app`) if you use the local port-publishing override. + # Provided by your Traefik deployment. If you use the port-publishing + # override instead, it redefines this as a local network. traefik_public: external: true diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index d2f6a59..ea26173 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -1,7 +1,25 @@ -FROM php:8.4-apache +# syntax=docker/dockerfile:1 +# ============================================================================= +# ADconsole container image — PHP 8.4 + Apache. Multi-stage: +# +# base : PHP + extensions + Apache (docroot = public/) + Composer + entrypoint +# dev : base only. The repo is bind-mounted at runtime (compose.dev.yaml); +# the entrypoint installs deps + generates APP_KEY on first boot and +# opcache re-checks files on every request, so edits are live. +# prod : base + the application COPYed in and `composer install --no-dev` +# run at build time → a self-contained image (docker/compose.yaml, or +# `docker build`) that you can tag and push straight from the repo; +# only .env and docker/data/ are mounted at runtime. +# +# Build context is the repo root. Default target is `prod` (last stage): +# docker build -f docker/php/Dockerfile -t adconsole:local . +# ============================================================================= + +FROM php:8.4-apache AS base # --- System packages + PHP extensions ------------------------------------- RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ libcurl4-openssl-dev \ libonig-dev \ libzip-dev \ @@ -11,21 +29,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && docker-php-ext-install -j"$(nproc)" curl mbstring zip pdo pdo_mysql ldap opcache \ && apt-get clean && rm -rf /var/lib/apt/lists/* -# Sensible opcache defaults for production. -RUN { \ - echo 'opcache.enable=1'; \ - echo 'opcache.enable_cli=0'; \ - echo 'opcache.memory_consumption=128'; \ - echo 'opcache.max_accelerated_files=20000'; \ - echo 'opcache.validate_timestamps=1'; \ - echo 'opcache.revalidate_freq=60'; \ - } > /usr/local/etc/php/conf.d/opcache.ini - -# Apache: enable rewrite + serve from public/. +# Apache: enable rewrite + serve from public/ + honour Laravel's public/.htaccess. ENV APACHE_DOCUMENT_ROOT=/var/www/html/public -RUN a2enmod rewrite \ +RUN a2enmod rewrite headers \ && sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \ - && sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf + && sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf \ + && printf '\n\tAllowOverride All\n\tRequire all granted\n\n' \ + > /etc/apache2/conf-available/adconsole.conf \ + && a2enconf adconsole # Composer (from the official image, no network installer). COPY --from=composer:2 /usr/bin/composer /usr/bin/composer @@ -33,7 +44,61 @@ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer COPY docker/php/entrypoint.sh /usr/local/bin/adconsole-entrypoint RUN chmod +x /usr/local/bin/adconsole-entrypoint +ENV COMPOSER_ALLOW_SUPERUSER=1 WORKDIR /var/www/html ENTRYPOINT ["adconsole-entrypoint"] CMD ["apache2-foreground"] + + +# ============================================================================= +# dev — no app code baked in; compose.dev.yaml bind-mounts the repo over +# /var/www/html. opcache revalidates on every hit so changes show immediately. +# ============================================================================= +FROM base AS dev + +ENV ADCONSOLE_ENV=dev + +RUN { \ + echo 'opcache.enable=1'; \ + echo 'opcache.enable_cli=0'; \ + echo 'opcache.validate_timestamps=1'; \ + echo 'opcache.revalidate_freq=0'; \ + } > /usr/local/etc/php/conf.d/opcache.ini + + +# ============================================================================= +# prod (default) — self-contained image. The build context is trimmed by +# .dockerignore (no vendor/, node_modules/, tests/, docs/, .env, ./data …). +# ============================================================================= +FROM base AS prod + +ENV ADCONSOLE_ENV=prod + +# Aggressive opcache: the code in the image never changes at runtime. +RUN { \ + echo 'opcache.enable=1'; \ + echo 'opcache.enable_cli=0'; \ + echo 'opcache.memory_consumption=128'; \ + echo 'opcache.max_accelerated_files=20000'; \ + echo 'opcache.validate_timestamps=0'; \ + } > /usr/local/etc/php/conf.d/opcache.ini + +# 1. Dependencies — manifests first so this layer survives code-only edits. +COPY composer.json composer.lock ./ +RUN composer install --no-dev --no-interaction --prefer-dist --no-scripts --no-autoloader + +# 2. Application code. +COPY . . + +# 3. Optimised autoloader + package discovery, then the writable runtime tree. +RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader \ + && mkdir -p \ + storage/framework/cache/data \ + storage/framework/sessions \ + storage/framework/views \ + storage/logs \ + storage/app/public \ + storage/app/private \ + bootstrap/cache \ + && chown -R www-data:www-data storage bootstrap/cache diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh index 80ca386..ac43f83 100644 --- a/docker/php/entrypoint.sh +++ b/docker/php/entrypoint.sh @@ -1,21 +1,30 @@ #!/bin/sh -# First-run bootstrap for the ADconsole container. The project is bind-mounted, -# so a fresh `git clone` has no vendor/ and no APP_KEY yet - handle both here so -# `docker compose up` just works. Safe to run on every start (all steps are -# idempotent). +# First-run / every-boot bootstrap for the ADconsole container. Every step is +# idempotent, so it is safe to run on each start. +# +# dev (ADCONSOLE_ENV=dev): the repo is bind-mounted. Install deps (incl. +# dev), generate APP_KEY, clear caches only — no +# config cache, so .env edits take effect on reload. +# prod (default): code + vendor are baked into the image. Just fix +# storage ownership, migrate and warm the caches. set -e cd /var/www/html if [ ! -f .env ]; then - echo "[adconsole] .env is missing - copy .env.example to .env and fill it in." >&2 + echo "[adconsole] .env is missing — mount your filled-in .env at /var/www/html/.env" >&2 + echo " (cp .env.example .env, edit it, then start the stack again)" >&2 exit 1 fi -# 1. PHP dependencies. +# 1. PHP dependencies — only when absent (fresh clone in dev; never in prod). if [ ! -f vendor/autoload.php ]; then echo "[adconsole] installing composer dependencies..." - composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader + if [ "$ADCONSOLE_ENV" = "dev" ]; then + composer install --no-interaction --prefer-dist + else + composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader + fi fi # 2. Application key. @@ -24,15 +33,25 @@ if ! grep -qE '^APP_KEY=.+' .env; then php artisan key:generate --force fi -# 3. Writable paths (bind-mounted repo is owned by the host user). +# 3. Writable storage tree — the bind mount / volume can start empty. +mkdir -p \ + storage/framework/cache/data \ + storage/framework/sessions \ + storage/framework/views \ + storage/logs \ + storage/app/public \ + storage/app/private \ + bootstrap/cache chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || true # 4. Database schema + storage symlink. php artisan migrate --force php artisan storage:link 2>/dev/null || true -# 5. Framework caches (config/route/view/event). Refreshed on every boot. +# 5. Framework caches. php artisan optimize:clear -php artisan optimize +if [ "$ADCONSOLE_ENV" != "dev" ]; then + php artisan optimize +fi exec "$@" diff --git a/CHANGELOG.md b/docs/CHANGELOG.md similarity index 73% rename from CHANGELOG.md rename to docs/CHANGELOG.md index 6de5ec6..c1d0719 100644 --- a/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,12 +8,49 @@ incompatible changes to configuration, the database schema or AD-permission requirements; **MINOR** for backwards-compatible features; **PATCH** for backwards-compatible fixes. Each release is a git tag `vX.Y.Z`; the latest tag on `main` is the supported version. Upgrade notes live in -[`docs/upgrading.md`](docs/upgrading.md). +[`upgrading.md`](upgrading.md). ## [Unreleased] _Nothing yet._ +## [1.1.0] - 2026-09-10 + +### Added + +- **Configuration backup.** `/settings` → *General* tab can now export the whole + panel configuration (general settings + attribute definitions) to a JSON file + and import it back. Export can optionally leave out the attribute-encryption + key. Both actions are recorded in the audit log (`config.export`, + `config.import`). No Active Directory data is included. +- **`docker/compose.dev.yaml`** — a live-reload development stack that + bind-mounts the working tree; `docker/php/Dockerfile` is now multi-stage with + `dev` / `prod` targets. Each stack has a no-Traefik override template. +- **Release image.** `.github/workflows/release.yml` builds the `prod` image on + every `vX.Y.Z` tag and pushes it to the Gitea container registry as + `gitea.kzbikowski.pl/kzbkowski/adconsole:{X.Y.Z, X.Y, latest}`. + `docker/compose.yaml` now defaults to that image (`ADCONSOLE_VERSION` pins a + tag, `ADCONSOLE_IMAGE` replaces the reference); `docker compose … up -d` + pulls it, `… --build` still rebuilds locally. + +### Changed + +- **Docker: the production image is now self-contained.** `docker/compose.yaml` + builds `docker/php/Dockerfile` target `prod`, which `COPY`s the app in and + runs `composer install --no-dev` at build time — the working tree is no longer + bind-mounted (only `.env`). Persistent state moved from Docker named volumes + to **`docker/data/app_storage`** and **`docker/data/db`** bind mounts. + Apache now honours Laravel's `public/.htaccess` (`AllowOverride All`). + + Run Compose from the repo root with `--env-file .env`, e.g. + `docker compose --env-file .env -f docker/compose.yaml up -d`. + + **Upgrading an existing Docker deployment:** copy the old named volumes into + `docker/data/` before the first `up` with the new file, e.g. + `docker run --rm -v adconsole_db_data:/from -v "$PWD/docker/data/db":/to alpine cp -a /from/. /to/` + (same for `adconsole_app_storage` → `docker/data/app_storage`). See + [upgrading.md](upgrading.md#v110--docker-layout). + ## [1.0.0] - 2026-09-08 First public release. Consolidates the prior internal builds (`0.1.0`) into a @@ -106,5 +143,6 @@ Initial internal release: users, computers, groups, read-only GPO, org chart, dashboard, panel configuration, PL/EN UI, LDAP-group login. Never tagged or published; superseded by `1.0.0`. -[Unreleased]: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/compare/v1.0.0...HEAD +[Unreleased]: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/compare/v1.1.0...HEAD +[1.1.0]: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/compare/v1.0.0...v1.1.0 [1.0.0]: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/releases/tag/v1.0.0 diff --git a/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md similarity index 100% rename from CODE_OF_CONDUCT.md rename to docs/CODE_OF_CONDUCT.md diff --git a/CONTRIBUTING.md b/docs/CONTRIBUTING.md similarity index 87% rename from CONTRIBUTING.md rename to docs/CONTRIBUTING.md index ce01aa0..48e164e 100644 --- a/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -6,8 +6,8 @@ very welcome. ## Before you start -- Read [`CLAUDE/AGENTS.md`](CLAUDE/AGENTS.md) (the architecture in one page) and - [`docs/architecture.md`](docs/architecture.md). +- Read [`CLAUDE/AGENTS.md`](../CLAUDE/AGENTS.md) (the architecture in one page) and + [`architecture.md`](architecture.md). - By participating you agree to the [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md). - For anything non-trivial, open an issue first so we can agree on the approach. @@ -20,7 +20,7 @@ very welcome. 3. **Keep it dependency-light.** No new runtime Composer/npm package without a clear reason in the issue. 4. **Least privilege.** New AD operations must be documented in - [`docs/active-directory.md`](docs/active-directory.md) with the exact right + [`active-directory.md`](active-directory.md) with the exact right they require. 5. **Security-sensitive by nature** — think about what a hostile panel user or a compromised service account could do with your change. @@ -35,7 +35,7 @@ php artisan test vendor/bin/pint ``` -See [`docs/development.md`](docs/development.md) for the details (SQLite for dev, +See [`development.md`](development.md) for the details (SQLite for dev, the fake `ldap` guard in tests, the harmless `DEPR` tag, etc.). ## Pull requests @@ -64,4 +64,4 @@ and steps to reproduce. ## Licence By contributing you agree your work is released under the project's -[MIT Licence](LICENSE). +[MIT Licence](../LICENSE). diff --git a/docs/README.md b/docs/README.md index e9c98f1..9a2996d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,11 @@ | [troubleshooting.md](troubleshooting.md) | Fix "the list is empty", "password reset fails", etc. | | [upgrading.md](upgrading.md) | Pull a new version safely. | | [publishing.md](publishing.md) | Turn this tree into a clean shared/public repo. | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Set up a dev loop and get a change merged. | +| [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Know the behaviour expected of contributors. | +| [CHANGELOG.md](CHANGELOG.md) | See what changed between releases. | +| [SECURITY.md](SECURITY.md) | Report a vulnerability; understand the security scope. | Start with the [project README](../README.md) for the 5-minute overview. -Security policy and vulnerability reporting: [../SECURITY.md](../SECURITY.md). +Security policy and vulnerability reporting: [SECURITY.md](SECURITY.md). diff --git a/SECURITY.md b/docs/SECURITY.md similarity index 93% rename from SECURITY.md rename to docs/SECURITY.md index b7384f0..f3d8ab3 100644 --- a/SECURITY.md +++ b/docs/SECURITY.md @@ -40,12 +40,12 @@ ADconsole is a privileged tool. When you deploy it, **you** are responsible for: - An encrypted LDAP channel (`LDAP_SSL` / `LDAP_TLS`) and, ideally, `LDAP_TLS_VERIFY=true`. - Delegating the service account **only** the AD rights you actually use - ([docs/active-directory.md](docs/active-directory.md)). + ([active-directory.md](active-directory.md)). - Keeping `AD_LOGIN_GROUP` small and audited. - Protecting `.env` (file mode `600`) — it contains the service-account password. - Applying updates promptly. -See the [hardening checklist](docs/deployment.md#hardening-checklist). +See the [hardening checklist](deployment.md#hardening-checklist). ## Supported versions diff --git a/docs/configuration.md b/docs/configuration.md index 83bd917..1c8b7f8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,7 +35,7 @@ panel's own config (`panel_settings`, `panel_attributes`) and the change log |---|---| | `DB_CONNECTION` | `mysql` (MariaDB/MySQL), `pgsql`, or `sqlite`. | | `DB_HOST` / `DB_PORT` / `DB_DATABASE` / `DB_USERNAME` / `DB_PASSWORD` | Connection. | -| `DB_ROOT_PASSWORD` | Only consumed by `docker/compose.yaml` on first volume init. | +| `DB_ROOT_PASSWORD` | Only consumed by `docker/compose.yaml` when it first initialises `docker/data/db`. | ### Sessions / cache / queue @@ -122,3 +122,19 @@ Locked rows that can't be removed or retyped: `login` (sAMAccountName), `distinguishedName`, the computed `status` and `password_expiry`, and `account_expiry` (fixed `date`, `accountExpires`). If deleted directly in SQL they are re-created from the defaults on the next read. + +### Backup / restore + +The *General* tab has a **Configuration backup** card: + +- **Export** downloads a JSON file with the General-tab settings and the full + attribute list (`App\Services\ConfigBackupService::export()`). Tick *leave the + encryption key out* to blank `encryption.key` in the file. +- **Import** reads such a file back and overwrites `panel_settings` (key `panel`) + and `panel_attributes`. Values are re-validated on the way in (ranges clamped, + locked attribute rows re-asserted), so a hand-edited file can't push the panel + into a bad state. A file that isn't a `format: adconsole-config` JSON, or that + declares a newer schema `version`, is rejected with a form error. + +Both actions write a `panel_audit` row (`config.export` / `config.import`). The +backup never contains Active Directory data or the audit log itself. diff --git a/docs/deployment.md b/docs/deployment.md index 5f89312..31b6715 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,17 +1,39 @@ # Deployment (Docker) -The repo ships a small production stack in `docker/`: +Everything Docker lives in `docker/`: +- **`docker/compose.yaml`** — production. A **self-contained image**: the app + code and `composer install --no-dev` are baked in at build time + (`docker/php/Dockerfile`, target `prod`). Nothing from the working tree is + mounted except `.env`. Persistent state lives in `docker/data/`. Wired for an + **external Traefik** proxy on the `traefik_public` network. +- **`docker/compose.dev.yaml`** — development / live-reload. Bind-mounts the + working tree over `/var/www/html`; edits to PHP / Blade / assets apply on the + next request. State is in throwaway named volumes. See + [development.md](development.md). +- `docker/compose.override.yaml.template` / + `docker/compose.dev.override.yaml.template` — copy each to the matching + `*.override.yaml` (git-ignored) to run **without** Traefik by publishing a + host port. - `docker/php/Dockerfile` — PHP 8.4 + Apache, `ldap`/`pdo_mysql`/`opcache`, - Composer, and an entrypoint that self-bootstraps on first boot. -- `docker/php/entrypoint.sh` — installs `vendor/` if missing, generates - `APP_KEY`, fixes `storage/` ownership, runs `migrate --force`, warms caches, - then starts Apache. Idempotent — runs on every start. -- `docker/compose.yaml` — `app` + `mariadb`, wired for an **external Traefik** - proxy on the `traefik_public` network. -- `docker/compose.override.yaml.template` — copy to - `docker/compose.override.yaml` (git-ignored) to run **without** Traefik by - publishing a host port. + Composer. Stages: `base` → `dev` (no code baked in) / `prod` (code + deps + baked in). +- `docker/php/entrypoint.sh` — on every boot: install `vendor/` if missing, + generate `APP_KEY`, create + `chown` the `storage/` tree, `migrate --force`, + warm caches (prod) / just clear them (dev). Idempotent. + +## Invocation + +Because the Compose files sit in `docker/`, always run **from the repo root** +and pass `--env-file .env` so `${DB_*}` / `${APP_DOMAIN}` interpolation reads +the repo-root `.env`: + +```sh +docker compose --env-file .env -f docker/compose.yaml +``` + +(Tip: `export COMPOSE_FILE=docker/compose.yaml COMPOSE_ENV_FILES=.env` once per +shell, then plain `docker compose …` works.) ## With Traefik @@ -23,51 +45,89 @@ cp .env.example .env # DB_PASSWORD / DB_ROOT_PASSWORD <- set real values # LDAP_* / AD_* <- your domain -docker compose -f docker/compose.yaml up -d --build -docker compose -f docker/compose.yaml logs -f app # watch the bootstrap +docker compose --env-file .env -f docker/compose.yaml up -d # pulls the release image +docker compose --env-file .env -f docker/compose.yaml logs -f app # watch bootstrap ``` +`docker/compose.yaml` defaults to the image the Gitea release workflow publishes +(`gitea.kzbikowski.pl/kzbkowski/adconsole:1.1.0`), so `up -d` **pulls** it — no +local build. Add `--build` to build from your working tree instead; the result +is tagged with the same name. + Enable a certificate resolver by uncommenting the `certresolver` label in `docker/compose.yaml` and setting its name to match your Traefik config. -The `.env` file is **bind-mounted**, not baked in: edit it and -`docker compose restart app` — no rebuild needed. (This is deliberate; see the -comment in `compose.yaml`.) +The `.env` file is **bind-mounted**, not baked into the image: edit it and +`docker compose … restart app` — no rebuild needed. Everything else is +immutable. + +### The image + +`.github/workflows/release.yml` builds `docker/php/Dockerfile` target `prod` on +every `vX.Y.Z` tag and pushes `…/adconsole:{X.Y.Z, X.Y, latest}` to the Gitea +container registry (needs Actions enabled and `packages: write`; set a +`REGISTRY_TOKEN` secret if the built-in token is not allowed to push). + +Deploy a specific release: + +```sh +ADCONSOLE_VERSION=1.1.0 \ + docker compose --env-file .env -f docker/compose.yaml pull +ADCONSOLE_VERSION=1.1.0 \ + docker compose --env-file .env -f docker/compose.yaml up -d +``` + +Set `ADCONSOLE_VERSION` (or the full `ADCONSOLE_IMAGE`) in `.env` to make it +stick. A different registry / private mirror: `ADCONSOLE_IMAGE=host/adconsole:tag`. +To publish from a workstation instead of CI: +`docker compose … build && docker compose … push`. ## Without Traefik ```sh cp docker/compose.override.yaml.template docker/compose.override.yaml # APP_URL=http://localhost:8080 in .env -docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build +docker compose --env-file .env \ + -f docker/compose.yaml -f docker/compose.override.yaml up -d --build ``` -App on `http://localhost:8080`, MariaDB on `localhost:3306`. Put a TLS-terminating -proxy (nginx, Caddy, your load balancer) in front for anything but a lab. +App on `http://localhost:8080`, MariaDB on `localhost:3306`. Put a +TLS-terminating proxy (nginx, Caddy, your load balancer) in front for anything +but a lab. ## Data & backups -| What | Where | Back up? | -|---|---|---| -| Database (`panel_settings` + `panel_attributes` config, `panel_audit` change log, sessions, cache) | `db_data` volume | **Yes** — `panel_settings` / `panel_attributes` are your config and `panel_audit` is your only record of who changed what. `mysqldump adconsole`. | -| `storage/` (logs, framework cache, sessions if file-driver) | `app_storage` volume | Optional (logs only really). | -| `.env` | bind-mounted from the repo | **Yes**, securely — it holds the service-account password. | +Production state is under **`docker/data/`** (git-ignored), created on first run: -Restore config elsewhere: `mysqldump`/import the `panel_settings` and -`panel_attributes` tables, or re-enter it in `/settings`. +| What | Path | Back up? | +|---|---|---| +| Database (`panel_settings` + `panel_attributes` config, `panel_audit` change log, sessions, cache) | `docker/data/db/` | **Yes** — `panel_settings` / `panel_attributes` are your config and `panel_audit` is your only record of who changed what. `docker compose … exec mariadb mariadb-dump -uroot -p adconsole`. | +| `storage/` (logs, framework cache, sessions if file-driver) | `docker/data/app_storage/` | Optional (logs only really). | +| `.env` | bind-mounted from the repo root | **Yes**, securely — it holds the service-account password. | + +A file-level copy of `docker/data/db/` is only consistent with the container +stopped; prefer `mariadb-dump` on a running stack. The panel's own config also +has a JSON export at `/settings` → *General* → *Configuration backup*. + +> On **rootless Docker** the MariaDB image can fail to `chown` a bind-mounted +> datadir. Either run the daemon rootful, or switch `docker/compose.yaml`'s +> `mariadb` volume to a named volume (as `docker/compose.dev.yaml` already does). **Audit-log retention.** `panel_audit` grows with panel activity and nothing -prunes it automatically. If you need a retention window, trim it with a scheduled -job of your own, e.g. +prunes it automatically. If you need a retention window, trim it with a +scheduled job of your own, e.g. `DELETE FROM panel_audit WHERE created_at < NOW() - INTERVAL 365 DAY;`, and back it up (or ship it to a SIEM) before trimming if the entries have compliance value. ## Upgrades +Bump `ADCONSOLE_VERSION` in `.env` (or `git pull` if you track `main` and rely +on the `latest` tag), then: + ```sh -git pull -docker compose -f docker/compose.yaml up -d --build +docker compose --env-file .env -f docker/compose.yaml pull +docker compose --env-file .env -f docker/compose.yaml up -d ``` The entrypoint runs `migrate --force` and `optimize` on the new container. See @@ -84,6 +144,7 @@ The entrypoint runs `migrate --force` and `optimize` on the new container. See - [ ] Panel reachable only from admin networks / VPN (proxy ACL or firewall) - [ ] DB not exposed to the internet; strong `DB_PASSWORD` / `DB_ROOT_PASSWORD` - [ ] `.env` file permissions `600`, owned by the deploy user +- [ ] `docker/data/` on a backed-up filesystem, permissions restricted to the deploy user - [ ] Log shipping / retention configured; review `LDAP_LOGGING` volume - [ ] Backups of the database tested diff --git a/docs/development.md b/docs/development.md index 520f0ea..e888358 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,6 +27,39 @@ You still need a reachable domain controller for the AD-backed screens. Without one, the auth and list pages will error — the test suite does **not** need a DC (it fakes the guard and mocks LdapRecord). +## Local setup — Docker (live reload) + +```sh +git clone https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git adconsole && cd adconsole +cp .env.example .env # APP_ENV=local, APP_DEBUG=true, real LDAP_* / AD_* +docker compose --env-file .env -f docker/compose.dev.yaml up --build +``` + +Run from the repo root; `--env-file .env` lets Compose interpolate `${DB_*}` +from the repo-root `.env` (the file lives in `docker/`). Set +`export COMPOSE_FILE=docker/compose.dev.yaml COMPOSE_ENV_FILES=.env` once to +drop the flags. + +The working tree is bind-mounted over `/var/www/html`, so PHP / Blade / CSS / JS +edits apply on the next request (the `dev` image target sets +`opcache.revalidate_freq=0`). On the first boot of a fresh clone the entrypoint +runs `composer install` (with dev deps) and `php artisan key:generate`, then +`migrate`. `vendor/` lands back on the host, so your IDE resolves it too. + +Database and `storage/` are throwaway named volumes — add `-v` to +`docker compose … down` to reset the box. MariaDB is published on +`localhost:3306`. Run artisan / tools inside the container: + +```sh +docker compose --env-file .env -f docker/compose.dev.yaml exec app php artisan migrate:fresh +docker compose --env-file .env -f docker/compose.dev.yaml exec app php artisan test +docker compose --env-file .env -f docker/compose.dev.yaml exec app vendor/bin/pint +``` + +`compose.dev.yaml` expects an external Traefik on `traefik_public`; without one, +`cp docker/compose.dev.override.yaml.template docker/compose.dev.override.yaml` +and add `-f docker/compose.dev.override.yaml` (publishes `http://localhost:8080`). + ## Tests ```sh diff --git a/docs/publishing.md b/docs/publishing.md index f287ae4..7076598 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -13,7 +13,7 @@ If you re-publish under a different owner/host, revisit these: | File | Was | Now / put | |---|---|---| | `README.md`, `docs/installation.md`, `docs/development.md` | `` in the `git clone` lines | `https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git` | -| `SECURITY.md` | `` | `mail@kzbikowski.pl` | +| `docs/SECURITY.md` | `` | `mail@kzbikowski.pl` | | `.github/ISSUE_TEMPLATE/config.yml` | `https://github.com/OWNER/REPO/...` | The repo's Gitea issue/wiki URLs | | `LICENSE` | `2026 Kacper Żbikowski` | Copyright year + holder, if different | | `lang/{pl,en}/pages.php` → `author.*` | `Kacper Żbikowski` | Author/maintainer shown on the in-app *About* page | @@ -33,7 +33,8 @@ none of it slipped in: - `vendor/`, `node_modules/`, `public/build/`, `public/hot/` - `storage/` runtime (logs, sessions, framework cache) — only the `.gitignore` keep-files should be tracked -- `docker/compose.override.yaml` +- `docker/compose.override.yaml`, `docker/compose.dev.override.yaml` +- `docker/data/` (production DB + storage state) - IDE / agent dirs (`.idea/`, `.vscode/`, `.claude/`, …) ```sh @@ -82,11 +83,18 @@ git push origin v1.0.0 `laravel`, `ldaprecord`, `admin-panel`, `self-hosted`. - Enable **Issues**. Point the "Question / help" contact link at the repo's issues (Gitea has no Discussions). -- The GitHub-format templates in `.github/` (issue/PR templates, the - Pint + PHPUnit CI workflow on PHP 8.2/8.3/8.4) are read by Gitea Actions too if - Actions is enabled on the instance; otherwise they are harmless. -- Cut a **release** from the `v1.0.0` tag and paste the matching `CHANGELOG.md` - section as the notes. +- The workflows in `.github/workflows/` are read by Gitea Actions if Actions is + enabled on the instance (otherwise harmless): + - `ci.yml` — Pint + PHPUnit on PHP 8.2/8.3/8.4, on every push / PR. + - `release.yml` — on a `vX.Y.Z` tag, builds `docker/php/Dockerfile` target + `prod` and pushes `…/adconsole:{X.Y.Z, X.Y, latest}` to the instance's + container registry. Needs `packages: write`; if the built-in token can't + push, add a `REGISTRY_TOKEN` repo secret. Adjust `REGISTRY` / `IMAGE_NAME` + in the workflow (and the default in `docker/compose.yaml`) for a different + host or owner. +- Release: `git tag -a vX.Y.Z -m "ADconsole vX.Y.Z" && git push origin vX.Y.Z` + (that fires `release.yml`), then cut a **release** from the tag on the Git host + and paste the matching `docs/CHANGELOG.md` section as the notes. ## 6. First-deploy sanity (for whoever installs it) diff --git a/docs/upgrading.md b/docs/upgrading.md index 6c96087..601685d 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -14,7 +14,7 @@ From `v1.0.0` on, releases are [semantic](https://semver.org/) git tags The latest tag on `main` is the supported version; there are no long-term-support branches. Per-version notes are under [Version notes](#version-notes) below and -in [`CHANGELOG.md`](../CHANGELOG.md). +in [`CHANGELOG.md`](CHANGELOG.md). ## General procedure @@ -29,13 +29,49 @@ php artisan optimize:clear php artisan optimize # config + route + view + event cache ``` -Docker: `docker compose -f docker/compose.yaml up -d --build` — the entrypoint -runs `migrate --force` and `optimize` for you. +Docker (from the repo root): bump `ADCONSOLE_VERSION` in `.env`, then +`docker compose --env-file .env -f docker/compose.yaml pull` and +`… up -d`. The entrypoint runs `migrate --force` and `optimize` for you. -Roll back = restore the DB dump and check out the previous revision. +Roll back = restore the DB dump and either check out the previous revision or set +`ADCONSOLE_VERSION` back to the previous tag. ## Version notes +### v1.1.0 — Docker layout + +The Docker files moved and the production image changed shape. One-time steps +for an **existing** Docker install: + +1. `git pull` — `docker/compose.yaml` now lives in `docker/`, builds a + self-contained image, and reads persistent state from **`docker/data/`** + bind mounts instead of the `adconsole_db_data` / `adconsole_app_storage` + named volumes. +2. Stop the old stack: `docker compose -f docker/compose.yaml down` (old file). +3. Copy the volume contents into `docker/data/` **before** the first new `up`: + + ```sh + mkdir -p docker/data/db docker/data/app_storage + docker run --rm -v adconsole_db_data:/from -v "$PWD/docker/data/db":/to \ + alpine cp -a /from/. /to/ + docker run --rm -v adconsole_app_storage:/from -v "$PWD/docker/data/app_storage":/to \ + alpine cp -a /from/. /to/ + ``` + +4. Start it, always from the repo root and with `--env-file .env`: + + ```sh + docker compose --env-file .env -f docker/compose.yaml up -d + ``` + + (`export COMPOSE_FILE=docker/compose.yaml COMPOSE_ENV_FILES=.env` once to drop + the flags.) `up -d` now **pulls** the released image; add `--build` to build + from the working tree. + +Rootless Docker can't `chown` a bind-mounted MariaDB datadir — run the daemon +rootful, or switch the `mariadb` volume in `docker/compose.yaml` to a named +volume. Nothing changes for a **bare-metal** install. + ### Panel configuration moved into the database Two migrations, both automatic — just run `php artisan migrate` (the Docker diff --git a/lang/en/audit.php b/lang/en/audit.php index c0f654b..4db3d20 100644 --- a/lang/en/audit.php +++ b/lang/en/audit.php @@ -37,6 +37,8 @@ return [ 'actions' => [ 'config.settings' => 'Panel settings', 'config.attributes' => 'Attribute configuration', + 'config.export' => 'Configuration export', + 'config.import' => 'Configuration import', 'user.create' => 'User created', 'user.update' => 'User edited', diff --git a/lang/en/config.php b/lang/en/config.php index fb19cb4..921b36f 100644 --- a/lang/en/config.php +++ b/lang/en/config.php @@ -74,6 +74,23 @@ return [ 'tpl_ldap_placeholder' => 'e.g. extensionAttribute5', ], + 'backup' => [ + 'heading' => 'Configuration backup', + 'intro' => 'Download the whole panel configuration (the settings from the General tab and the attribute definitions) as a JSON file, or restore it from a file you saved earlier. The backup does not include Active Directory data or the audit log.', + 'export_heading' => 'Export (download file)', + 'export_btn' => 'Download backup', + 'exclude_key' => 'Leave the attribute encryption key out of the file', + 'import_heading' => 'Import (load file)', + 'import_btn' => 'Load and overwrite', + 'import_confirm' => 'Loading a file overwrites the current panel configuration and the attribute list. Continue?', + 'warning' => 'The backup file holds the full panel configuration — including the attribute encryption key unless you tick the option to leave it out. Keep it somewhere safe.', + 'flash_imported' => 'Configuration loaded from the backup file.', + 'error_not_json' => 'The file could not be read — it is not valid JSON.', + 'error_bad_format' => 'This file is not an ADconsole configuration backup.', + 'error_bad_version' => 'The backup file comes from a newer version of the panel and cannot be loaded.', + 'error_empty' => 'The backup file contains no settings or attributes.', + ], + 'flash' => [ 'saved' => 'Panel configuration has been saved.', 'attributes_saved' => 'Attribute configuration has been saved.', diff --git a/lang/pl/audit.php b/lang/pl/audit.php index bf049d2..2f7d97b 100644 --- a/lang/pl/audit.php +++ b/lang/pl/audit.php @@ -37,6 +37,8 @@ return [ 'actions' => [ 'config.settings' => 'Ustawienia panelu', 'config.attributes' => 'Konfiguracja atrybutów', + 'config.export' => 'Eksport konfiguracji', + 'config.import' => 'Import konfiguracji', 'user.create' => 'Utworzenie użytkownika', 'user.update' => 'Edycja użytkownika', diff --git a/lang/pl/config.php b/lang/pl/config.php index 7f546f7..a03c7f5 100644 --- a/lang/pl/config.php +++ b/lang/pl/config.php @@ -74,6 +74,23 @@ return [ 'tpl_ldap_placeholder' => 'np. extensionAttribute5', ], + 'backup' => [ + 'heading' => 'Kopia zapasowa konfiguracji', + 'intro' => 'Pobierz całą konfigurację panelu (ustawienia z zakładki Ogólne oraz definicje atrybutów) do pliku JSON albo wczytaj ją z wcześniej pobranego pliku. Kopia nie obejmuje danych z Active Directory ani dziennika zmian.', + 'export_heading' => 'Eksport (pobierz plik)', + 'export_btn' => 'Pobierz kopię', + 'exclude_key' => 'Pomiń w pliku klucz szyfrujący atrybuty', + 'import_heading' => 'Import (wczytaj plik)', + 'import_btn' => 'Wczytaj i nadpisz', + 'import_confirm' => 'Wczytanie pliku nadpisze bieżącą konfigurację panelu oraz listę atrybutów. Kontynuować?', + 'warning' => 'Plik kopii zawiera pełną konfigurację panelu — w tym klucz szyfrujący atrybuty, o ile nie zaznaczysz opcji jego pominięcia. Przechowuj go w bezpiecznym miejscu.', + 'flash_imported' => 'Konfigurację wczytano z pliku kopii zapasowej.', + 'error_not_json' => 'Nie udało się odczytać pliku — to nie jest poprawny JSON.', + 'error_bad_format' => 'Ten plik nie jest kopią zapasową konfiguracji ADconsole.', + 'error_bad_version' => 'Plik kopii pochodzi z nowszej wersji panelu i nie może zostać wczytany.', + 'error_empty' => 'Plik kopii nie zawiera żadnych ustawień ani atrybutów.', + ], + 'flash' => [ 'saved' => 'Konfiguracja panelu została zapisana.', 'attributes_saved' => 'Konfiguracja atrybutów została zapisana.', diff --git a/resources/views/admin/config/index.blade.php b/resources/views/admin/config/index.blade.php index 826c9fc..45eb706 100644 --- a/resources/views/admin/config/index.blade.php +++ b/resources/views/admin/config/index.blade.php @@ -133,6 +133,55 @@ + +
+
+
+
+
{{ __('config.backup.heading') }}
+

{!! __('config.backup.intro') !!}

+ +
+ {{-- Eksport --}} +
+
+
{{ __('config.backup.export_heading') }}
+
+ + +
+ +
+
+ + {{-- Import --}} +
+
+ @csrf +
{{ __('config.backup.import_heading') }}
+ + @error('config_file') +
{{ $message }}
+ @enderror + +
+
+
+ +
+ + {!! __('config.backup.warning') !!} +
+
+
+
+
{{-- ================= ZAKŁADKA: ATRYBUTY ================= --}} diff --git a/routes/web.php b/routes/web.php index 2de01d6..d0c195e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -140,4 +140,8 @@ Route::prefix('settings')->name('config.')->middleware('auth:ldap')->group(funct Route::get('/', [ConfigController::class, 'index'])->name('index'); Route::put('/', [ConfigController::class, 'update'])->name('update'); Route::put('/attributes', [ConfigController::class, 'updateAttributes'])->name('attributes.update'); + + // Kopia zapasowa konfiguracji panelu (bez danych z AD). + Route::get('/export', [ConfigController::class, 'export'])->name('export'); + Route::post('/import', [ConfigController::class, 'import'])->name('import'); }); diff --git a/tests/Feature/ConfigBackupTest.php b/tests/Feature/ConfigBackupTest.php new file mode 100644 index 0000000..d0ea7db --- /dev/null +++ b/tests/Feature/ConfigBackupTest.php @@ -0,0 +1,175 @@ +actingAsPanelAdmin(); + } + + /** @param array $overrides */ + protected function backupFile(array $overrides = []): UploadedFile + { + $payload = array_replace( + app(ConfigBackupService::class)->export(), + $overrides, + ); + + return UploadedFile::fake()->createWithContent( + 'adconsole-config.json', + json_encode($payload, JSON_UNESCAPED_UNICODE), + ); + } + + public function test_export_downloads_a_json_backup_with_the_expected_shape(): void + { + $res = $this->get('/settings/export')->assertOk(); + + $this->assertStringContainsString('attachment', $res->headers->get('content-disposition')); + $this->assertStringContainsString('adconsole-config-', $res->headers->get('content-disposition')); + + $res->assertJsonPath('format', 'adconsole-config') + ->assertJsonPath('version', 1) + ->assertJsonStructure(['format', 'version', 'exported_at', 'settings', 'attributes']); + + $data = $res->json(); + $this->assertSame(42, $data['settings']['password_policy']['max_age_days']); + // Pełna lista domyślna: 24 atrybuty AD + 2 kolumny wyliczane. + $this->assertCount(26, $data['attributes']); + } + + public function test_export_can_leave_out_the_encryption_key(): void + { + $this->put('/settings', [ + 'reset_password' => ['must_change_password' => '1', 'unlock_account' => '1'], + 'password_policy' => ['max_age_days' => '42'], + 'encryption' => ['key' => 'TopSecret!'], + ])->assertRedirect(); + + $this->get('/settings/export') + ->assertJsonPath('settings.encryption.key', 'TopSecret!') + ->assertJsonPath('encryption_key_included', true); + + $this->get('/settings/export?exclude_key=1') + ->assertJsonPath('settings.encryption.key', '') + ->assertJsonPath('encryption_key_included', false); + } + + public function test_import_restores_general_settings_and_attributes(): void + { + // Zmień konfigurację, zrób kopię, przywróć domyślne, wczytaj kopię. + $this->put('/settings', [ + 'reset_password' => ['must_change_password' => '0', 'unlock_account' => '0'], + 'password_policy' => ['max_age_days' => '17'], + 'audit' => ['require_reason' => '1'], + ])->assertRedirect(); + + $this->put('/settings/attributes', [ + 'attributes' => [ + ['key' => 'title', 'label' => 'Stanowisko', 'ldap' => 'title', 'category' => 'org', 'type' => 'text', 'column' => '1'], + ['key' => 'badgeId', 'label' => 'Identyfikator', 'ldap' => 'extensionAttribute9', 'category' => 'hr', 'type' => 'text', 'column' => '1'], + ], + ])->assertRedirect(); + + $file = $this->backupFile(); + + // Wróć do domyślnych. + app(PanelSettingsService::class)->save(app(PanelSettingsService::class)->defaults()); + app(PanelSettingsService::class)->saveAttributes([]); + $this->assertSame(42, app(PanelSettingsService::class)->passwordMaxAgeDays()); + + $this->post('/settings/import', ['config_file' => $file]) + ->assertRedirect() + ->assertSessionHas('status') + ->assertSessionHasNoErrors(); + + $fresh = app(PanelSettingsService::class); + $this->assertSame(17, $fresh->passwordMaxAgeDays()); + $this->assertTrue($fresh->requireReason()); + + $byKey = collect($fresh->attributes())->keyBy('key'); + $this->assertTrue($byKey->has('badgeId')); + $this->assertSame('extensionAttribute9', $byKey['badgeId']['ldap']); + $this->assertTrue($byKey['title']['column']); + + // Pozycje systemowe / wyliczane zostają odtworzone przez saveAttributes(). + $this->assertTrue($byKey->has('login')); + $this->assertTrue($byKey->has('status')); + } + + public function test_import_writes_an_audit_entry(): void + { + $this->post('/settings/import', ['config_file' => $this->backupFile()]) + ->assertRedirect(); + + $this->assertDatabaseHas('panel_audit', ['action' => 'config.import']); + } + + public function test_import_rejects_a_file_that_is_not_an_adconsole_backup(): void + { + $file = UploadedFile::fake()->createWithContent('nope.json', json_encode(['hello' => 'world'])); + + $this->post('/settings/import', ['config_file' => $file]) + ->assertRedirect() + ->assertSessionHasErrors('config_file'); + + $this->assertDatabaseMissing('panel_audit', ['action' => 'config.import']); + } + + public function test_import_rejects_a_file_that_is_not_valid_json(): void + { + $file = UploadedFile::fake()->createWithContent('broken.json', '{ not json '); + + $this->post('/settings/import', ['config_file' => $file]) + ->assertRedirect() + ->assertSessionHasErrors('config_file'); + } + + public function test_import_rejects_a_backup_from_a_newer_schema_version(): void + { + $file = $this->backupFile(['version' => 999]); + + $this->post('/settings/import', ['config_file' => $file]) + ->assertRedirect() + ->assertSessionHasErrors('config_file'); + } + + public function test_import_sanitises_out_of_range_values_from_a_hand_edited_file(): void + { + $file = $this->backupFile([ + 'settings' => [ + 'password_policy' => ['max_age_days' => 999999, 'generator_length' => 2], + 'audit' => ['require_reason' => 'yes'], + ], + ]); + + $this->post('/settings/import', ['config_file' => $file])->assertRedirect()->assertSessionHasNoErrors(); + + $fresh = app(PanelSettingsService::class); + $this->assertSame(3650, $fresh->passwordMaxAgeDays()); + $this->assertSame(8, $fresh->passwordGenerator()['length']); + $this->assertTrue($fresh->requireReason()); + } + + public function test_settings_page_shows_the_backup_card(): void + { + $this->get('/settings') + ->assertOk() + ->assertSee(__('config.backup.heading')) + ->assertSee(route('config.export'), escape: false); + } +}