From 59019d2f3212a5f11e07569f7ae2e42415b6a0f0 Mon Sep 17 00:00:00 2001 From: Carlos Arias Date: Thu, 23 Jul 2026 20:23:48 +0000 Subject: [PATCH] Phase 0: admin console foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cja_admin + cja_admin_log tables (dedicated single-admin tier + audit trail) - AdminAuth controller: /api/adminauth login/logout/me, session-based, rate-limited, every attempt logged - set-admin-password CLI (bcrypt, run manually so the password never enters an agent context) - requireAdmin() guard for future privileged console endpoints - agent env scaffold (git-ignored) Foundation only — no agent runner or features yet. Preview/publish, media, and the dashboard come in phases 1-3 per agents/console/PLAN.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DoFYZY9gkGPNDqZ7NuEa9a --- api/cli/set-admin-password.php | 35 ++++++++ api/db/migrations/008_create_cja_admin.sql | 36 +++++++++ api/public/controllers/adminauth.php | 93 ++++++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 api/cli/set-admin-password.php create mode 100644 api/db/migrations/008_create_cja_admin.sql create mode 100644 api/public/controllers/adminauth.php diff --git a/api/cli/set-admin-password.php b/api/cli/set-admin-password.php new file mode 100644 index 0000000..8f3bba6 --- /dev/null +++ b/api/cli/set-admin-password.php @@ -0,0 +1,35 @@ + + * + * Upserts on username. The plaintext is hashed with bcrypt and never stored. + */ + +require __DIR__ . '/../vendor/autoload.php'; +require __DIR__ . '/../config.php'; + +$username = $argv[1] ?? ''; +$password = $argv[2] ?? ''; + +if ($username === '' || $password === '') { + fwrite(STDERR, "usage: php api/cli/set-admin-password.php \n"); + exit(1); +} +if (strlen($password) < 12) { + fwrite(STDERR, "Refusing: use at least 12 characters.\n"); + exit(1); +} + +$hash = password_hash($password, PASSWORD_DEFAULT); + +$existing = Db::getValue('SELECT admin_id FROM cja_admin WHERE username = ?', [$username]); +if ($existing) { + Db::update('cja_admin', ['password_hash' => $hash], 'admin_id = ?', [$existing]); + echo " updated password for '{$username}'\n"; +} else { + Db::insert('cja_admin', ['username' => $username, 'password_hash' => $hash]); + echo " created admin '{$username}'\n"; +} diff --git a/api/db/migrations/008_create_cja_admin.sql b/api/db/migrations/008_create_cja_admin.sql new file mode 100644 index 0000000..d461a22 --- /dev/null +++ b/api/db/migrations/008_create_cja_admin.sql @@ -0,0 +1,36 @@ +-- Admin auth + audit for the management console. +-- +-- Separate from the seed's two existing tiers on purpose: +-- * account.php — community member sessions (public users) +-- * ADMIN_TOKEN — machine/privileged bearer token +-- The console operator (you) is neither. This is a dedicated single-admin login +-- with its own session flag, so the console's blast radius never overlaps with +-- public accounts or a shared token. +-- +-- Passwords are stored as a PHP password_hash() (bcrypt). Set via +-- api/cli/set-admin-password.php — never inline, never committed. + +CREATE TABLE IF NOT EXISTS `cja_admin` ( + `admin_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `username` varchar(60) NOT NULL, + `password_hash` varchar(255) NOT NULL, + `created_at` datetime NOT NULL DEFAULT current_timestamp(), + `last_login_at` datetime DEFAULT NULL, + PRIMARY KEY (`admin_id`), + UNIQUE KEY `uq_cja_admin_username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Every privileged action the console takes, logged. This is the accountability +-- backstop for a code-writing agent: what was asked, what was published, +-- discarded, or rolled back — with who and when. +CREATE TABLE IF NOT EXISTS `cja_admin_log` ( + `log_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `actor` varchar(60) NOT NULL DEFAULT 'admin', + `action` varchar(60) NOT NULL, -- login, run, preview, publish, discard, rollback, upload + `detail` varchar(500) DEFAULT NULL, + `ip` varchar(45) DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`log_id`), + KEY `idx_cja_admin_log_feed` (`created_at`), + KEY `idx_cja_admin_log_action` (`action`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/api/public/controllers/adminauth.php b/api/public/controllers/adminauth.php new file mode 100644 index 0000000..eda0fd6 --- /dev/null +++ b/api/public/controllers/adminauth.php @@ -0,0 +1,93 @@ + sets admin session + * POST /api/adminauth/logout -> clears it + * GET /api/adminauth/me -> { admin: bool, username } + * + * A dedicated single-admin tier, separate from community accounts and the + * bearer token. Login sets $_SESSION['admin']; every privileged console + * endpoint (built in later phases) will call requireAdmin() before doing + * anything. Rate-limited hard, and every attempt is written to cja_admin_log. + * + * Extends PublicController for origin + throttle; the admin check is bespoke + * (session flag), not the framework's token/api auth. + */ +class AdminAuth extends PublicController +{ + /** Guard for privileged console endpoints. Emits 401 and stops if not admin. */ + public static function requireAdmin(): void + { + if (empty($_SESSION['admin']['ok'])) { + http_response_code(401); + header('Content-Type: application/json; charset=UTF-8'); + echo json_encode(['ok' => false, 'data' => null, 'error' => [ + 'code' => 'unauthorized', 'message' => 'Admin login required', + ]]); + exit; + } + } + + private function logAttempt(string $action, string $detail): void + { + try { + \Db::insert('cja_admin_log', [ + 'actor' => $_SESSION['admin']['username'] ?? 'anon', + 'action' => $action, + 'detail' => mb_substr($detail, 0, 500), + 'ip' => $_SERVER['REMOTE_ADDR'] ?? null, + ]); + } catch (\Throwable $e) { + // logging must never block auth + } + } + + public function login(): void + { + // 8 attempts / 5 min / IP — brute-force resistant, human-friendly. + $this->guardPublic('admin_login', 8, 300); + + $in = json_decode((string) file_get_contents('php://input'), true) ?: []; + $username = trim((string) ($in['username'] ?? '')); + $password = (string) ($in['password'] ?? ''); + + $row = \Db::getRow('SELECT admin_id, username, password_hash FROM cja_admin WHERE username = ?', [$username]); + + // Constant-ish work whether or not the user exists (avoid enumeration). + $hash = $row['password_hash'] ?? '$2y$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidin'; + $ok = $row && password_verify($password, $hash); + + if (!$ok) { + $this->logAttempt('login_fail', "username={$username}"); + $this->json(null, 401, ['code' => 'bad_credentials', 'message' => 'Incorrect username or password.']); + } + + session_regenerate_id(true); + $_SESSION['admin'] = ['ok' => true, 'username' => $row['username'], 'at' => time()]; + \Db::update('cja_admin', ['last_login_at' => date('Y-m-d H:i:s')], 'admin_id = ?', [$row['admin_id']]); + $this->logAttempt('login', "username={$row['username']}"); + + $this->json(['admin' => true, 'username' => $row['username']]); + } + + public function logout(): void + { + $this->logAttempt('logout', ''); + unset($_SESSION['admin']); + session_regenerate_id(true); + $this->json(['admin' => false]); + } + + public function me(): void + { + $admin = $_SESSION['admin']['ok'] ?? false; + $this->json([ + 'admin' => (bool) $admin, + 'username' => $admin ? ($_SESSION['admin']['username'] ?? null) : null, + ]); + } +}