diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index b0dedc42..046e9c88 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -15,6 +15,6 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@v3 + uses: actions/checkout@v5 - name: 'Dependency Review' - uses: actions/dependency-review-action@v3 + uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 86c9461c..750b784e 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -10,9 +10,15 @@ permissions: contents: read jobs: - build: + test: + name: Unit tests (PHP ${{ matrix.php }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3'] + services: mariadb: image: mariadb:10.5.29 @@ -28,13 +34,13 @@ jobs: --health-retries=10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - name: Set up PHP + - name: Set up PHP ${{ matrix.php }} uses: shivammathur/setup-php@v2 with: - php-version: '8.2' - extensions: mysqli, curl, mbstring, json + php-version: ${{ matrix.php }} + extensions: mysqli, curl, mbstring, json, bz2 coverage: none - name: Validate composer.json and composer.lock @@ -42,16 +48,19 @@ jobs: - name: Cache Composer packages id: composer-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: vendor - key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-php${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} restore-keys: | - ${{ runner.os }}-php- + ${{ runner.os }}-php${{ matrix.php }}- - name: Install dependencies run: composer install --prefer-dist --no-progress + - name: Composer audit (security advisories) + run: composer audit --no-dev + - name: Run unit tests env: KYTE_DB_HOST: 127.0.0.1 @@ -60,3 +69,36 @@ jobs: KYTE_DB_DATABASE: kytedev KYTE_DB_CHARSET: utf8 run: vendor/bin/phpunit --testsuite unit + + static-analysis: + name: PHPStan static analysis + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Set up PHP 8.2 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: mysqli, curl, mbstring, json, bz2 + coverage: none + + - name: Cache Composer packages + uses: actions/cache@v5 + with: + path: vendor + key: ${{ runner.os }}-php8.2-phpstan-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php8.2-phpstan- + ${{ runner.os }}-php8.2- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run PHPStan + # Level 1 with a baseline of the existing codebase's pre-existing + # findings (phpstan-baseline.neon). Going forward this enforces + # "no new level-1 violations"; the baseline shrinks as legacy + # patterns get cleaned up over time. + run: vendor/bin/phpstan analyse --memory-limit=512M --no-progress diff --git a/CHANGELOG.md b/CHANGELOG.md index f73f4ae2..17870075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,85 @@ +## 4.4.0 + +> Phase 2 (MCP server) + Phase 2.5 (sensitive-data flag) + Phase 3 (JWT auth) all ship together. **No breaking changes** — every addition is opt-in, defaults preserve v4.3.x behavior bit-for-bit. Existing customers can upgrade in place without code changes. + +### New Feature: Embedded MCP server (Phase 2) + +Each Kyte install can now expose a Model Context Protocol endpoint that lets AI clients (Claude Code, Claude.ai) inspect controllers, models, pages, and functions over a tenant-scoped bearer. + +- **New endpoint**: `POST /mcp` — handled by `Mcp\Endpoint` outside the standard MVC pipeline. JSON-RPC over HTTP, protocol version `2025-06-18`. Bypasses Kyte's HMAC envelope. +- **New strategy**: `McpTokenStrategy` registered with the auth dispatcher. Strict `Authorization: Bearer kmcp_live_…` prefix match. +- **New model**: `KyteMCPToken` — opaque bearer tokens. Stored as sha256 hash, displayed as 16-char prefix for identification. Scoped (`read` / `draft` / `commit`), revokable, optional expiry, optional CIDR allowlist, optional application-binding. +- **New controller**: `KyteMCPTokenController` — issue, list, revoke. Generates the raw token at issuance (returned once, never recoverable). Force-overrides `kyte_account` from auth context to close a privilege-escalation vector. Emits `MCP_TOKEN_ISSUE` / `MCP_TOKEN_REVOKE` / `MCP_TOKEN_USE` / `MCP_SCOPE_VIOLATION` audit rows. +- **10 read tools shipped**: `list_applications`, `list_controllers`, `read_controller`, `list_functions`, `read_function` (with optional `version_number`), `list_models`, `read_model`, `list_sites`, `list_pages`, `read_page` (with optional `version_number`). +- **Scope enforcement**: `ScopedCallToolHandler` registered ahead of the SDK's default handler. Tools declare required scope via `#[RequiresScope('read'|'draft'|'commit')]` attribute. Fail-closed default — a tool without the attribute is unreachable. +- **Account isolation**: every tool re-asserts `entity.kyte_account === api.account.id`. Foreign ids return `null` / `[]`, never the foreign record. +- **bzip2 decompression**: `Controller.code`, `Function.code`, and `KytePageVersionContent.{html,stylesheet,javascript}` are stored compressed; `Bz2Codec::decompressIfBz2` wraps the read tools so source is returned in plaintext to the client. +- **Origin validation** on `/mcp`: per spec § Security, mitigates DNS rebinding. Policy is "no Origin → allow, Origin present → check `MCP_ALLOWED_ORIGINS` constant (CSV)". CLI clients (Claude Code) unaffected; restrictive default forces operator opt-in for browser origins. +- **Proxy-aware client IP**: `Kyte\Mcp\Util\ClientIp` reads `CF-Connecting-IP` then `X-Forwarded-For` first hop then falls back to `REMOTE_ADDR`. Gated on `KYTE_TRUST_PROXY_IP_HEADERS` constant — default-off so installs without a proxy aren't exposed to header spoofing. Wired into `McpTokenStrategy::clientIp()` for IP allowlist enforcement and audit fields. + +### New Feature: Sensitive-data flag (Phase 2.5) + +A three-tier opt-in flag that prevents activity/error logs from capturing request bodies, and gates MCP read tools from exposing source for flagged entities. Designed for pass-through controllers whose payload contents are regulated data the platform should not store. + +- **New columns** (default `0`, no behavior change unless flipped): + - `Controller.sensitive` — blanket flag. When `1`, drops body+response from logs entirely. Handles virtual (no-model) pass-through controllers. + - `DataModel.sensitive` — same blanket treatment when the model is the request target. + - `ModelAttribute.sensitive` — per-field redaction. Distinct from existing `.protected` (which only blanks values in GET responses). Set both for both behaviors. + - `KytePage.sensitive` — MCP-only; withholds `html`/`stylesheet`/`javascript` from `read_page`. Pages don't write to activity logs. +- **New service**: `SensitivityPolicy` (`src/Core/SensitivityPolicy.php`) — single source of truth, per-request singleton with in-memory cache keyed by `(scope, name, account)`. One DB hit per tuple per request. Fail-permissive on lookup error so transient DB issues degrade to existing `SENSITIVE_FIELDS` baseline rather than to no redaction at all. +- **ActivityLogger** consults the policy before persisting `request_data` and the PUT changes diff. Blanket-sensitive → both fields null. Field-sensitive → flagged fields replaced with `[REDACTED]`, other fields pass through. The hardcoded `SENSITIVE_FIELDS` list (password / token / secret_key / etc.) still runs as a baseline on top. +- **ErrorHandler** previously captured request body AND response payload into `KyteError` with zero redaction — closed in this release. `handleException`, `handleError`, and `outputBufferCallback` all consult the policy now. AI error-correction queue (`AIErrorCorrection::queueForAnalysis`) gains its own defense-in-depth check at the top: skips any sensitive-origin row, with audit-log breadcrumb. Regulated data never reaches Anthropic for analysis. +- **MCP read tools** gated by the same flag: sensitive controller → `read_controller` returns `code: null` + `sensitive: true`; sensitive function (via parent controller) → same; sensitive model → `read_model` returns `definition: null` + `sensitive: true`; sensitive field → stripped from `definition.struct`, listed separately in `sensitive_fields`; sensitive page → content fields null. List tools (`list_controllers`, `list_models`, `list_pages`) surface a `sensitive: bool` on each row so AI clients see up front which entities are gated. +- **Runtime API responses unaffected** — a sensitive controller still returns its normal response to the caller. The flag governs log / MCP / AI exposure only, not the live contract. + +### New Feature: JWT bearer authentication (Phase 3) + +Modern auth path that coexists with the legacy HMAC sign/rotate. Customers can run a mix of HMAC apps and JWT apps on the same install. + +- **New strategy**: `JwtSessionStrategy` — HS256 access tokens with a configurable secret, claims `{iss, sub, aud, exp, iat, nbf, jti, email, app}`. Strict `Authorization: Bearer eyJ…` prefix match (won't clash with MCP's `kmcp_live_…`). Decode + verify in `preAuth`, sets `$api->user`, `$api->account`, and `$api->session->hasSession = true` so the standard ModelController auth gate accepts the request. +- **New endpoint family**: `POST /jwt/login`, `POST /jwt/refresh`, `POST /jwt/logout`, `POST /jwt/logout-all` — handled by `JwtEndpoint` (same pattern as `Mcp\Endpoint`, bypasses the MVC pipeline so login can run pre-auth). Login posts `{email, password, app_identifier?}` and returns `{access_token, refresh_token, expires_in, token_type:'Bearer', refresh_expires_at}`. +- **Refresh tokens**: opaque (`kref_v1_…` prefix), stored as sha256 hash in new `KyteRefreshToken` table. Single-use rotation per RFC 6819 — every successful refresh revokes the presented token and issues a new one in the same family. **Reuse detection**: presenting a revoked token revokes the entire family (likely leak signal). Expiration without revocation does not trigger family kill. +- **Multilogon**: separate logins always create separate families. A user signing in from laptop and phone gets two independent families — revoking one device does not affect the other. Distinct from HMAC's `ALLOW_MULTILOGON` flag. +- **`AuthDispatcher::buildDefault()`** now registers three strategies: McpToken → JwtSession → Hmac. Each `matches()` is strict so order doesn't affect correctness; order is documented for review clarity. HMAC clients continue working unchanged via `HmacSessionStrategy`. +- **`Application.auth_mode`** column added: `'hmac'` (default) or `'jwt'`. Drives whether Shipyard's page generator emits the v1.x HMAC constructor or the v2 JWT constructor for kyte-api-js consumers. +- **Configuration constants** (define in `config.php` to enable JWT): + - `KYTE_JWT_SECRET` — required for JWT mode. At least 256 bits of entropy. Never commit to version control. + - `KYTE_JWT_ISSUER` — optional, defaults to `'kyte'`. Mismatched tokens are rejected at preAuth. + - `KYTE_JWT_ACCESS_TTL` — optional, default 900 seconds. + - `KYTE_JWT_REFRESH_TTL` — optional, default 604800 seconds (7 days). +- **Dependency**: `firebase/php-jwt ^7.0` — lightweight, well-known, no transitive deps. + +### Bundled migrations + +Three new SQL files in `migrations/`. Apply in order. All are additive (new columns / new table) — safe to apply ahead of code; new columns default to `0` / `'hmac'` so legacy code sees no behavior change until the matching feature is opted into. + +``` +migrations/4.4.0_sensitive_columns.sql # Controller / DataModel / ModelAttribute / KytePage .sensitive +migrations/4.4.0_jwt_refresh_tokens.sql # KyteRefreshToken table +migrations/4.4.0_application_auth_mode.sql # Application.auth_mode +``` + +### CI hardening + +- **PHP matrix** in `.github/workflows/php.yml`: tests now run on PHP 8.2 AND 8.3 against MariaDB 10.5.29. Catches version-specific syntax / deprecation issues. +- **PHPStan** static analysis at level 1 with a baseline of pre-existing findings (`phpstan-baseline.neon`). New code is held to zero level-1 violations; baseline only shrinks. +- **Composer audit** step on every push — fails CI on a new advisory in production dependencies. + +### Test coverage + +- 180 unit tests, 531 assertions. Up from 109 at end of Phase 2. +- New test files: `SensitivityPolicyTest`, `ActivityLoggerSensitivityTest`, `ErrorHandlerSensitivityTest`, `McpSensitivityTest`, `JwtSessionStrategyTest`, `JwtEndpointTest`, `RefreshTokenStoreTest`. +- Includes a regression test for the `hasSession` integration bug surfaced during dev rollout: JwtSessionStrategy must mark `$api->session->hasSession = true` after validating the bearer, otherwise `ModelController::authenticate()` rejects every protected endpoint despite a valid JWT. + +### Upgrade notes + +1. Apply the three migrations above to your database. +2. If using JWT mode: define `KYTE_JWT_SECRET` in `config.php` (generate with `openssl rand -base64 48`). HMAC-only installs need no config change. +3. If you were running with `AUTH_STRATEGY_DISPATCHER='shadow'` from v4.3.0, you can now safely flip to `'on'` to activate the dispatcher. HMAC traffic routes through the new `HmacSessionStrategy` which has been shadow-verified bit-identical to the inline auth. +4. kyte-api-js v2.0+ is required on the client side for JWT apps. v1.x continues to work unchanged for HMAC apps. + +--- + ## 4.1.1 ### Bug Fix: ActivityLogger blocking exception on dynamically-loaded app models diff --git a/composer.json b/composer.json index d2c63ee8..f60c366c 100644 --- a/composer.json +++ b/composer.json @@ -3,16 +3,22 @@ "description": "Light-weight framework for developing versatile PHP applications.", "license": "MIT", "require": { - "php": ">=7.2", + "php": ">=8.1", "ext-curl": "*", "ext-json": "*", "ext-mbstring": "*", "aws/aws-sdk-php": "^3.101", "stripe/stripe-php": "^7.2", - "dragonmantank/cron-expression": "^3.3" + "dragonmantank/cron-expression": "^3.3", + "mcp/sdk": "^0.4", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "laminas/laminas-httphandlerrunner": "^2.12", + "firebase/php-jwt": "^7.0" }, "require-dev": { - "phpunit/phpunit": "*" + "phpunit/phpunit": "*", + "phpstan/phpstan": "^2.1" }, "authors": [ { diff --git a/migrations/4.4.0_application_auth_mode.sql b/migrations/4.4.0_application_auth_mode.sql new file mode 100644 index 00000000..f365640f --- /dev/null +++ b/migrations/4.4.0_application_auth_mode.sql @@ -0,0 +1,19 @@ +-- ========================================================================= +-- Kyte v4.4.0 - Application.auth_mode column +-- ========================================================================= +-- IMPORTANT: Backup your database before running this migration. +-- +-- Adds a per-Application `auth_mode` setting that Shipyard's page +-- generators consult when emitting the kyte-api-js constructor call. +-- +-- 'hmac' (default) → preserves v1.x behavior; pages use HMAC sign/rotate. +-- 'jwt' → pages use the v2 JWT bearer flow (kyte-api-js >= 2.0). +-- +-- Default 'hmac' means existing apps are no-op until the operator +-- explicitly opts in via the Shipyard Application form. +-- +-- See src/Mvc/Model/Application.php for the model spec. +-- ========================================================================= + +ALTER TABLE `Application` + ADD COLUMN `auth_mode` VARCHAR(16) NOT NULL DEFAULT 'hmac'; diff --git a/migrations/4.4.0_jwt_refresh_tokens.sql b/migrations/4.4.0_jwt_refresh_tokens.sql new file mode 100644 index 00000000..d24428c1 --- /dev/null +++ b/migrations/4.4.0_jwt_refresh_tokens.sql @@ -0,0 +1,48 @@ +-- ========================================================================= +-- Kyte v4.4.0 - JWT refresh token storage +-- ========================================================================= +-- IMPORTANT: Backup your database before running this migration. +-- +-- Creates the KyteRefreshToken table used by the JWT auth strategy. Refresh +-- tokens are opaque (NOT JWTs) and exchanged at /jwt/refresh for a fresh +-- (access_jwt, new_refresh_token) pair. Each rotation revokes the presented +-- token and issues a new one in the same `token_family` — presenting a +-- revoked token is treated as a leak signal and revokes the entire family. +-- +-- See src/Mvc/Model/KyteRefreshToken.php for the model spec and reuse +-- detection rationale. +-- ========================================================================= + +CREATE TABLE IF NOT EXISTS `KyteRefreshToken` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + `token_hash` VARCHAR(64) NOT NULL COMMENT 'sha256 hex of the raw refresh token', + `token_prefix` VARCHAR(32) NOT NULL COMMENT 'First chars of the raw token (kref_v1_...) for identification', + `token_family` VARCHAR(64) NOT NULL COMMENT 'Hex uuid shared by every token in a rotation chain', + + `user` BIGINT UNSIGNED NOT NULL, + `application` BIGINT UNSIGNED DEFAULT NULL, + + `expires_at` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `last_used_at` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `last_used_ip` VARCHAR(45) DEFAULT NULL, + + `revoked_at` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `revoked_reason` VARCHAR(64) DEFAULT NULL COMMENT 'rotated | reuse_detected | logout | admin_revoke | expired', + `rotated_to` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Successor token id when rotated; 0 while active', + + `kyte_account` BIGINT UNSIGNED NOT NULL, + + `created_by` BIGINT UNSIGNED DEFAULT NULL, + `date_created` BIGINT UNSIGNED DEFAULT NULL, + `modified_by` BIGINT UNSIGNED DEFAULT NULL, + `date_modified` BIGINT UNSIGNED DEFAULT NULL, + `deleted_by` BIGINT UNSIGNED DEFAULT NULL, + `date_deleted` BIGINT UNSIGNED DEFAULT NULL, + `deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0, + + UNIQUE KEY `idx_token_hash` (`token_hash`), + KEY `idx_token_family` (`token_family`), + KEY `idx_user` (`user`), + KEY `idx_account_expires` (`kyte_account`, `expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/migrations/4.4.0_sensitive_columns.sql b/migrations/4.4.0_sensitive_columns.sql new file mode 100644 index 00000000..d173c270 --- /dev/null +++ b/migrations/4.4.0_sensitive_columns.sql @@ -0,0 +1,40 @@ +-- ========================================================================= +-- Kyte v4.4.0 - Sensitive-data flag columns +-- ========================================================================= +-- IMPORTANT: Backup your database before running this migration. +-- +-- Adds a per-row `sensitive` boolean (0/1, default 0) to framework tables +-- that participate in the SensitivityPolicy. When set, the value gates +-- whether activity-log and error-log writers store the request body / +-- response payload, and whether MCP read tools expose source / definition / +-- content for that controller, model, field, or page. Default 0 means +-- existing installs are no-op until a flag is set. +-- +-- See SensitivityPolicy::class for the runtime evaluation order: +-- 1. Controller.sensitive blanket drop (handles no-model controllers) +-- 2. DataModel.sensitive blanket drop when the model is the target +-- 3. ModelAttribute.sensitive per-field redaction +-- 4. KytePage.sensitive MCP read-only — withholds page HTML/CSS/JS +-- from read_page. Pages don't write to logs, +-- so this tier only affects MCP exposure. +-- +-- ModelAttribute.sensitive is distinct from the existing +-- ModelAttribute.protected column (which only blanks values in GET +-- responses). Set both if both behaviors are desired. +-- ========================================================================= + +ALTER TABLE `Controller` + ADD COLUMN `sensitive` TINYINT UNSIGNED NOT NULL DEFAULT 0 + AFTER `application`; + +ALTER TABLE `DataModel` + ADD COLUMN `sensitive` TINYINT UNSIGNED NOT NULL DEFAULT 0 + AFTER `application`; + +ALTER TABLE `ModelAttribute` + ADD COLUMN `sensitive` TINYINT UNSIGNED NOT NULL DEFAULT 0 + AFTER `password`; + +ALTER TABLE `KytePage` + ADD COLUMN `sensitive` TINYINT UNSIGNED NOT NULL DEFAULT 0 + AFTER `state`; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 00000000..1a6774eb --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,649 @@ +parameters: + ignoreErrors: + - + message: '#^Access to an undefined property \$this\(Kyte\\AI\\AIErrorAnalyzer\)\:\:\$lastCost\.$#' + identifier: property.notFound + count: 2 + path: src/AI/AIErrorAnalyzer.php + + - + message: '#^Access to an undefined property \$this\(Kyte\\AI\\AIErrorAnalyzer\)\:\:\$lastOutputTokens\.$#' + identifier: property.notFound + count: 1 + path: src/AI/AIErrorAnalyzer.php + + - + message: '#^Access to an undefined property \$this\(Kyte\\AI\\AIErrorAnalyzer\)\:\:\$lastRequestId\.$#' + identifier: property.notFound + count: 1 + path: src/AI/AIErrorAnalyzer.php + + - + message: '#^Access to an undefined property Kyte\\AI\\AIErrorAnalyzer\:\:\$lastCost\.$#' + identifier: property.notFound + count: 1 + path: src/AI/AIErrorAnalyzer.php + + - + message: '#^Access to an undefined property Kyte\\AI\\AIErrorAnalyzer\:\:\$lastInputTokens\.$#' + identifier: property.notFound + count: 1 + path: src/AI/AIErrorAnalyzer.php + + - + message: '#^Access to an undefined property Kyte\\AI\\AIErrorAnalyzer\:\:\$lastOutputTokens\.$#' + identifier: property.notFound + count: 1 + path: src/AI/AIErrorAnalyzer.php + + - + message: '#^Access to an undefined property Kyte\\AI\\AIErrorAnalyzer\:\:\$lastRequestId\.$#' + identifier: property.notFound + count: 1 + path: src/AI/AIErrorAnalyzer.php + + - + message: '#^Constant AWS_ACCESS_KEY_ID not found\.$#' + identifier: constant.notFound + count: 1 + path: src/AI/AIErrorAnalyzer.php + + - + message: '#^Constant AWS_SECRET_KEY not found\.$#' + identifier: constant.notFound + count: 1 + path: src/AI/AIErrorAnalyzer.php + + - + message: '#^Constant AWS_ACCESS_KEY_ID not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Aws/Credentials.php + + - + message: '#^Constant AWS_SECRET_KEY not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Aws/Credentials.php + + - + message: '#^Caught class AwsException not found\.$#' + identifier: class.notFound + count: 12 + path: src/Aws/S3.php + + - + message: '#^Constant ALLOW_ENC_HANDOFF not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Core/Api.php + + - + message: '#^Constant ALLOW_MULTILOGON not found\.$#' + identifier: constant.notFound + count: 2 + path: src/Core/Api.php + + - + message: '#^Constant API_URL not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Core/Api.php + + - + message: '#^Constant APP_DATE_FORMAT not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Core/Api.php + + - + message: '#^Constant APP_LANG not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Core/Api.php + + - + message: '#^Constant AUTH_STRATEGY_DISPATCHER not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Core/Api.php + + - + message: '#^Constant IS_PRIVATE not found\.$#' + identifier: constant.notFound + count: 2 + path: src/Core/Api.php + + - + message: '#^Constant PAGE_SIZE not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Core/Api.php + + - + message: '#^Constant PASSWORD_FIELD not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Core/Api.php + + - + message: '#^Constant SESSION_TIMEOUT not found\.$#' + identifier: constant.notFound + count: 2 + path: src/Core/Api.php + + - + message: '#^Constant SIGNATURE_TIMEOUT not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Core/Api.php + + - + message: '#^Constant USERNAME_FIELD not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Core/Api.php + + - + message: '#^Constant VERBOSE_LOG not found\.$#' + identifier: constant.notFound + count: 3 + path: src/Core/Api.php + + - + message: '#^Constant APP_LANG not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Core/Auth/HmacSessionStrategy.php + + - + message: '#^Constant IS_PRIVATE not found\.$#' + identifier: constant.notFound + count: 3 + path: src/Core/Auth/HmacSessionStrategy.php + + - + message: '#^Constant SIGNATURE_TIMEOUT not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Core/Auth/HmacSessionStrategy.php + + - + message: '#^Constant VERBOSE_LOG not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Core/Auth/HmacSessionStrategy.php + + - + message: '#^Call to an undefined static method Kyte\\Core\\DBI\:\:setCharsetApp\(\)\.$#' + identifier: staticMethod.notFound + count: 1 + path: src/Core/DBI.php + + - + message: '#^Call to an undefined static method Kyte\\Core\\DBI\:\:setEngineApp\(\)\.$#' + identifier: staticMethod.notFound + count: 1 + path: src/Core/DBI.php + + - + message: '#^Caught class Kyte\\Core\\mysqli_sql_exception not found\.$#' + identifier: class.notFound + count: 1 + path: src/Core/DBI.php + + - + message: '#^Undefined variable\: \$charsetApp$#' + identifier: variable.undefined + count: 1 + path: src/Core/DBI.php + + - + message: '#^Undefined variable\: \$engineApp$#' + identifier: variable.undefined + count: 1 + path: src/Core/DBI.php + + - + message: '#^Undefined variable\: \$tbl_name_news$#' + identifier: variable.undefined + count: 1 + path: src/Core/DBI.php + + - + message: '#^Constant RETURN_NO_MODEL not found\.$#' + identifier: constant.notFound + count: 2 + path: src/Core/ModelObject.php + + - + message: '#^Constant STRICT_TYPING not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Core/ModelObject.php + + - + message: '#^Method Kyte\\Core\\ModelObject\:\:validateRequiredParams\(\) should return bool but return statement is missing\.$#' + identifier: return.missing + count: 1 + path: src/Core/ModelObject.php + + - + message: '#^Variable \$id in isset\(\) is never defined\.$#' + identifier: isset.variable + count: 1 + path: src/Core/ModelObject.php + + - + message: '#^Call to an undefined method Kyte\\Cron\\AIErrorAnalysisCron\:\:logError\(\)\.$#' + identifier: method.notFound + count: 3 + path: src/Cron/AIErrorAnalysisCron.php + + - + message: '#^Call to an undefined static method Kyte\\Core\\Model\:\:create\(\)\.$#' + identifier: staticMethod.notFound + count: 1 + path: src/Cron/CronJobManager.php + + - + message: '#^Call to an undefined static method Kyte\\Core\\Model\:\:one\(\)\.$#' + identifier: staticMethod.notFound + count: 3 + path: src/Cron/CronJobManager.php + + - + message: '#^Static call to instance method Kyte\\Core\\Model\:\:delete\(\)\.$#' + identifier: method.staticCall + count: 1 + path: src/Cron/CronJobManager.php + + - + message: '#^Call to an undefined method Kyte\\Mvc\\Controller\\AIErrorDeduplicationController\:\:error\(\)\.$#' + identifier: method.notFound + count: 6 + path: src/Mvc/Controller/AIErrorDeduplicationController.php + + - + message: '#^Call to an undefined method Kyte\\Mvc\\Controller\\AIErrorDeduplicationController\:\:success\(\)\.$#' + identifier: method.notFound + count: 5 + path: src/Mvc/Controller/AIErrorDeduplicationController.php + + - + message: '#^Constant KYTE_USE_SNS not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/ApplicationController.php + + - + message: '#^Constant SNS_QUEUE_SITE_MANAGEMENT not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/ApplicationController.php + + - + message: '#^Constant SNS_REGION not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/ApplicationController.php + + - + message: '#^Variable \$credential might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: src/Mvc/Controller/ApplicationController.php + + - + message: '#^Variable \$site might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: src/Mvc/Controller/ApplicationController.php + + - + message: '#^Result of method Kyte\\Mvc\\Controller\\CronJobController\:\:handleRecover\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: src/Mvc/Controller/CronJobController.php + + - + message: '#^Result of method Kyte\\Mvc\\Controller\\CronJobController\:\:handleRollback\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: src/Mvc/Controller/CronJobController.php + + - + message: '#^Result of method Kyte\\Mvc\\Controller\\CronJobController\:\:handleStats\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: src/Mvc/Controller/CronJobController.php + + - + message: '#^Result of method Kyte\\Mvc\\Controller\\CronJobController\:\:handleTrigger\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: src/Mvc/Controller/CronJobController.php + + - + message: '#^Result of method Kyte\\Mvc\\Controller\\CronJobExecutionController\:\:handleFailed\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: src/Mvc/Controller/CronJobExecutionController.php + + - + message: '#^Result of method Kyte\\Mvc\\Controller\\CronJobExecutionController\:\:handlePending\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: src/Mvc/Controller/CronJobExecutionController.php + + - + message: '#^Result of method Kyte\\Mvc\\Controller\\CronJobExecutionController\:\:handleRecent\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: src/Mvc/Controller/CronJobExecutionController.php + + - + message: '#^Result of method Kyte\\Mvc\\Controller\\CronJobExecutionController\:\:handleRunning\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: src/Mvc/Controller/CronJobExecutionController.php + + - + message: '#^Result of method Kyte\\Mvc\\Controller\\CronJobExecutionController\:\:handleStatistics\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: src/Mvc/Controller/CronJobExecutionController.php + + - + message: '#^Result of method Kyte\\Mvc\\Controller\\CronJobFunctionController\:\:handleRollback\(\) \(void\) is used\.$#' + identifier: method.void + count: 1 + path: src/Mvc/Controller/CronJobFunctionController.php + + - + message: '#^Caught class Kyte\\Mvc\\Controller\\Exception not found\.$#' + identifier: class.notFound + count: 1 + path: src/Mvc/Controller/KyteFunctionVersionController.php + + - + message: '#^Constant KYTE_USE_SNS not found\.$#' + identifier: constant.notFound + count: 2 + path: src/Mvc/Controller/KyteLibraryController.php + + - + message: '#^Constant SNS_QUEUE_SITE_MANAGEMENT not found\.$#' + identifier: constant.notFound + count: 2 + path: src/Mvc/Controller/KyteLibraryController.php + + - + message: '#^Constant SNS_REGION not found\.$#' + identifier: constant.notFound + count: 2 + path: src/Mvc/Controller/KyteLibraryController.php + + - + message: '#^Constant KYTE_JS_CDN not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/KytePageController.php + + - + message: '#^Constant KYTE_USE_SNS not found\.$#' + identifier: constant.notFound + count: 3 + path: src/Mvc/Controller/KytePageController.php + + - + message: '#^Constant SNS_QUEUE_SITE_MANAGEMENT not found\.$#' + identifier: constant.notFound + count: 3 + path: src/Mvc/Controller/KytePageController.php + + - + message: '#^Constant SNS_REGION not found\.$#' + identifier: constant.notFound + count: 3 + path: src/Mvc/Controller/KytePageController.php + + - + message: '#^Undefined variable\: \$app$#' + identifier: variable.undefined + count: 2 + path: src/Mvc/Controller/KytePageController.php + + - + message: '#^Constant KYTE_USE_SNS not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/KytePageDataController.php + + - + message: '#^Constant SNS_QUEUE_SITE_MANAGEMENT not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/KytePageDataController.php + + - + message: '#^Constant SNS_REGION not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/KytePageDataController.php + + - + message: '#^Caught class Kyte\\Mvc\\Controller\\Exception not found\.$#' + identifier: class.notFound + count: 1 + path: src/Mvc/Controller/KytePageVersionController.php + + - + message: '#^Constant APP_EMAIL not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/KytePasswordResetController.php + + - + message: '#^Constant APP_SES_REGION not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/KytePasswordResetController.php + + - + message: '#^Constant SHIPYARD_URL not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/KytePasswordResetController.php + + - + message: '#^Constant KYTE_USE_SNS not found\.$#' + identifier: constant.notFound + count: 2 + path: src/Mvc/Controller/KyteScriptController.php + + - + message: '#^Constant SNS_QUEUE_SITE_MANAGEMENT not found\.$#' + identifier: constant.notFound + count: 2 + path: src/Mvc/Controller/KyteScriptController.php + + - + message: '#^Constant SNS_REGION not found\.$#' + identifier: constant.notFound + count: 2 + path: src/Mvc/Controller/KyteScriptController.php + + - + message: '#^Caught class Kyte\\Mvc\\Controller\\Exception not found\.$#' + identifier: class.notFound + count: 1 + path: src/Mvc/Controller/KyteScriptVersionController.php + + - + message: '#^Constant SNS_KYTE_SHIPYARD_UPDATE not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/KyteShipyardUpdateController.php + + - + message: '#^Constant SNS_REGION not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/KyteShipyardUpdateController.php + + - + message: '#^Constant SNS_QUEUE_SITE_MANAGEMENT not found\.$#' + identifier: constant.notFound + count: 2 + path: src/Mvc/Controller/KyteSiteController.php + + - + message: '#^Constant SNS_REGION not found\.$#' + identifier: constant.notFound + count: 2 + path: src/Mvc/Controller/KyteSiteController.php + + - + message: '#^Constant STRICT_TYPING not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/ModelController.php + + - + message: '#^Constant extTableModel not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/ModelController.php + + - + message: '#^Undefined variable\: \$conditions$#' + identifier: variable.undefined + count: 1 + path: src/Mvc/Controller/ModelController.php + + - + message: '#^Constant KYTE_USE_SNS not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/NavigationController.php + + - + message: '#^Constant SNS_QUEUE_SITE_MANAGEMENT not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/NavigationController.php + + - + message: '#^Constant SNS_REGION not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/NavigationController.php + + - + message: '#^Variable \$nav might not be defined\.$#' + identifier: variable.undefined + count: 11 + path: src/Mvc/Controller/NavigationController.php + + - + message: '#^Constant PASSWORD_FIELD not found\.$#' + identifier: constant.notFound + count: 2 + path: src/Mvc/Controller/SessionController.php + + - + message: '#^Constant SESSION_RETURN_FK not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/SessionController.php + + - + message: '#^Constant USERNAME_FIELD not found\.$#' + identifier: constant.notFound + count: 2 + path: src/Mvc/Controller/SessionController.php + + - + message: '#^Constant USE_SESSION_MAP not found\.$#' + identifier: constant.notFound + count: 2 + path: src/Mvc/Controller/SessionController.php + + - + message: '#^Constant KYTE_USE_SNS not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/SideNavController.php + + - + message: '#^Constant SNS_QUEUE_SITE_MANAGEMENT not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/SideNavController.php + + - + message: '#^Constant SNS_REGION not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Mvc/Controller/SideNavController.php + + - + message: '#^Variable \$nav might not be defined\.$#' + identifier: variable.undefined + count: 11 + path: src/Mvc/Controller/SideNavController.php + + - + message: '#^Constant APP_EMAIL not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Util/Email.php + + - + message: '#^Constant APP_NAME not found\.$#' + identifier: constant.notFound + count: 1 + path: src/Util/Email.php + + - + message: '#^Deprecated in PHP 8\.0\: Required parameter \$region follows optional parameter \$sender\.$#' + identifier: parameter.requiredAfterOptional + count: 1 + path: src/Util/Email.php + + - + message: '#^Constant AWS_ACCESS_KEY_ID not found\.$#' + identifier: constant.notFound + count: 1 + path: tests/AwsCredentialTest.php + + - + message: '#^Constant AWS_SECRET_KEY not found\.$#' + identifier: constant.notFound + count: 1 + path: tests/AwsCredentialTest.php + + - + message: '#^Constant AWS_ACCESS_KEY_ID not found\.$#' + identifier: constant.notFound + count: 1 + path: tests/AwsS3Test.php + + - + message: '#^Constant SIGNATURE_TIMEOUT not found\.$#' + identifier: constant.notFound + count: 2 + path: tests/HmacSessionStrategyTest.php + + - + message: '#^Array has 2 duplicate keys with value ''created_by'' \(''created_by'', ''created_by''\)\.$#' + identifier: array.duplicateKey + count: 1 + path: tests/ModelTest.php + + - + message: '#^Constant SIGNATURE_TIMEOUT not found\.$#' + identifier: constant.notFound + count: 2 + path: tests/SignatureTest.php diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..c24aa3fc --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,29 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 1 + paths: + - src + - tests + excludePaths: + - vendor + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: false + ignoreErrors: + # Kyte's global model-name constants (Application, Controller, etc.) + # are defined at runtime by Api::loadModelsAndControllers via define() + # calls. PHPStan can't see them statically. + - '#Constant [A-Z][A-Za-z]+ not found\.#' + # Per-install config constants (KYTE_JWT_SECRET, KYTE_TRUST_PROXY_IP_HEADERS, + # AUTH_STRATEGY_DISPATCHER, etc.) are defined in each install's + # config.php. Defensive defined() checks at call sites are the + # runtime guard; the static analyzer can't see them. + - '#Constant [A-Z][A-Z_0-9]+ not found\.#' + # Same for tools that reference `KyteAccount`, `KyteError`, etc. + - '#Class [A-Z][A-Za-z]+ referenced with incorrect case as#' + # AllowDynamicProperties attribute on ModelObject permits dynamic + # property access which is the whole point of that class. + - '#Access to an undefined property Kyte\\Core\\ModelObject::#' + bootstrapFiles: + - vendor/autoload.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 256c0cb2..a6085fd0 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,11 +2,28 @@ + tests/ActivityLoggerSensitivityTest.php tests/ApiTest.php + tests/Bz2CodecTest.php + tests/ClientIpTest.php tests/DatabaseTest.php + tests/ErrorHandlerSensitivityTest.php tests/FunctionTest.php tests/HmacSessionStrategyTest.php + tests/JwtEndpointTest.php + tests/JwtSessionStrategyTest.php + tests/McpControllerToolsTest.php + tests/McpEndpointTest.php + tests/McpModelToolsTest.php + tests/McpPageToolsTest.php + tests/McpScopeTest.php + tests/McpSensitivityTest.php + tests/McpTokenControllerTest.php + tests/McpTokenStrategyTest.php tests/ModelTest.php + tests/RefreshTokenStoreTest.php + tests/SaveSafeSessionTest.php + tests/SensitivityPolicyTest.php tests/SignatureTest.php tests/VersionTest.php diff --git a/src/AI/AIErrorCorrection.php b/src/AI/AIErrorCorrection.php index 9f4739a1..c367fa14 100644 --- a/src/AI/AIErrorCorrection.php +++ b/src/AI/AIErrorCorrection.php @@ -27,6 +27,21 @@ class AIErrorCorrection */ public static function queueForAnalysis($error, $apiContext) { try { + // Defense-in-depth: ErrorHandler already gates this call, but + // re-check here so any future caller can't accidentally route + // a sensitive payload to the AI analysis pipeline (which would + // forward request body context to a third-party LLM). + $modelName = isset($apiContext->model) ? $apiContext->model : null; + $accountId = isset($apiContext->account->id) ? (int)$apiContext->account->id : null; + if ($modelName !== null && $accountId !== null) { + $policy = \Kyte\Core\SensitivityPolicy::getInstance(); + if ($policy->shouldDropPayload($modelName, $modelName, $accountId) + || !empty($policy->getSensitiveFields($modelName, $accountId))) { + error_log("AI Error Correction: skipping sensitive-context error for model '$modelName'"); + return; + } + } + // Quick checks (should we analyze?) if (!self::shouldAnalyze($error, $apiContext)) { return; diff --git a/src/Core/ActivityLogger.php b/src/Core/ActivityLogger.php index c9e4a424..8827b5ef 100644 --- a/src/Core/ActivityLogger.php +++ b/src/Core/ActivityLogger.php @@ -113,6 +113,26 @@ public function log($action, $modelName, $field, $value, $requestData, $response $duration = round((microtime(true) - $this->requestStartTime) * 1000); + // Consult SensitivityPolicy. If the controller or model is flagged + // sensitive, drop the request body and the changes diff entirely. + // Otherwise apply per-field policy redaction in addition to the + // hardcoded SENSITIVE_FIELDS baseline. The model name is used for + // both the controller-tier and model-tier lookups — for model- + // bound controllers it matches both Controller.name and + // DataModel.name; for no-model controllers only the controller + // tier resolves, which is the intended scope. + $shouldDrop = SensitivityPolicy::getInstance()->shouldDropPayload( + $modelName, $modelName, $this->accountId + ); + + $requestDataForLog = null; + if (!$shouldDrop && $requestData) { + $redacted = is_array($requestData) + ? SensitivityPolicy::getInstance()->redactFields($requestData, $modelName, $this->accountId) + : $requestData; + $requestDataForLog = json_encode($this->redactSensitive($redacted)); + } + $logData = [ // WHO 'user_id' => $this->userId, @@ -128,7 +148,7 @@ public function log($action, $modelName, $field, $value, $requestData, $response 'record_id' => $recordId, 'field' => $field, 'value' => $value ? substr((string)$value, 0, 255) : null, - 'request_data' => $requestData ? json_encode($this->redactSensitive($requestData)) : null, + 'request_data' => $requestDataForLog, 'changes' => null, // RESULT 'response_code' => $responseCode, @@ -147,9 +167,10 @@ public function log($action, $modelName, $field, $value, $requestData, $response 'kyte_account' => $this->accountId, ]; - // For PUT actions, compute changes diff - if ($action === 'PUT' && $this->preUpdateState !== null && is_array($requestData)) { - $changes = $this->computeChanges($this->preUpdateState, $requestData); + // For PUT actions, compute changes diff — but only when the + // payload isn't being dropped entirely. + if ($action === 'PUT' && !$shouldDrop && $this->preUpdateState !== null && is_array($requestData)) { + $changes = $this->computeChanges($this->preUpdateState, $requestData, $modelName); if (!empty($changes)) { $logData['changes'] = json_encode($changes); } @@ -242,11 +263,21 @@ public function redactSensitive($data) { } /** - * Compute changes between old state and new data + * Compute changes between old state and new data. + * + * Per-field redaction consults SensitivityPolicy first (model-aware, + * configurable via ModelAttribute.sensitive) and the hardcoded + * SENSITIVE_FIELDS list as a baseline. Either marks a field for + * '[REDACTED]' in both old and new positions. */ - private function computeChanges($oldState, $newData) { + private function computeChanges($oldState, $newData, $modelName = null) { $changes = []; + $policyFields = $modelName !== null + ? SensitivityPolicy::getInstance()->getSensitiveFields($modelName, $this->accountId) + : []; + $policyFieldsLower = array_map('strtolower', $policyFields); + foreach ($newData as $field => $newValue) { // Skip internal/audit fields if (in_array($field, ['id', 'created_by', 'date_created', 'modified_by', 'date_modified', 'deleted', 'deleted_by', 'date_deleted'])) { @@ -254,11 +285,13 @@ private function computeChanges($oldState, $newData) { } $keyLower = strtolower($field); - $isSensitive = false; - foreach (self::SENSITIVE_FIELDS as $sensitiveField) { - if (strpos($keyLower, strtolower($sensitiveField)) !== false) { - $isSensitive = true; - break; + $isSensitive = in_array($keyLower, $policyFieldsLower, true); + if (!$isSensitive) { + foreach (self::SENSITIVE_FIELDS as $sensitiveField) { + if (strpos($keyLower, strtolower($sensitiveField)) !== false) { + $isSensitive = true; + break; + } } } diff --git a/src/Core/Api.php b/src/Core/Api.php index f48ed309..78a8268a 100644 --- a/src/Core/Api.php +++ b/src/Core/Api.php @@ -185,6 +185,22 @@ class Api */ public $authStrategy = null; + /** + * Validated MCP bearer token for /mcp requests. Null on every other code + * path. Populated by Mcp\Endpoint after McpTokenStrategy::preAuth runs. + * + * @var \Kyte\Core\ModelObject|null + */ + public $mcpToken = null; + + /** + * Parsed scopes (e.g. ['read','draft','commit']) from the validated MCP + * token. Empty on every other code path. + * + * @var string[] + */ + public $mcpScopes = []; + /** * Model definition cache * @@ -602,6 +618,33 @@ private function loadModelsAndControllers() */ public function route() { try { + // /mcp is served by Mcp\Endpoint, which runs the MCP SDK and + // emits its own JSON-RPC / SSE response. Bypass Kyte's MVC + // pipeline (auth, session, response envelope, controller dispatch) + // entirely — none of it applies to MCP. + $path = ltrim((string)parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/'); + $firstSegment = explode('/', $path)[0] ?? ''; + if (strcasecmp($firstSegment, 'mcp') === 0) { + // $this->key / $this->account are normally instantiated below; + // Mcp\Endpoint::handle() will populate $this->account itself + // after the dispatcher runs, but it expects the empty slots to + // exist first. + $this->key = new \Kyte\Core\ModelObject(KyteAPIKey); + $this->account = new \Kyte\Core\ModelObject(KyteAccount); + \Kyte\Mcp\Endpoint::handle($this); + return; + } + + // /jwt/{login,refresh,logout,logout-all} are served by + // JwtEndpoint. Same rationale as /mcp — these aren't model + // CRUD endpoints, they emit their own JSON shapes, and they + // must run BEFORE auth (login can't require auth, refresh + // uses a refresh token not an access token, etc.). + if (strcasecmp($firstSegment, 'jwt') === 0) { + \Kyte\Core\Auth\JwtEndpoint::handle($this); + return; + } + if (isset($_SERVER['HTTP_X_KYTE_APPID'])) { $this->appId = $_SERVER['HTTP_X_KYTE_APPID']; } diff --git a/src/Core/Auth/AuthDispatcher.php b/src/Core/Auth/AuthDispatcher.php index 85dd34ac..b18d4d8b 100644 --- a/src/Core/Auth/AuthDispatcher.php +++ b/src/Core/Auth/AuthDispatcher.php @@ -41,13 +41,25 @@ public function select(): ?AuthStrategy } /** - * Convenience constructor wiring the default strategy stack for the - * current migration state. Phase 1: Hmac only. Phase 2 adds McpToken. - * Phase 3 adds JwtSession. + * Convenience constructor wiring the default strategy stack. + * + * Order matters. Strategies are tried in declaration order; the + * first that claims the request wins. The chosen order below relies + * on each strategy's matches() being strict — McpToken only claims + * Bearer `kmcp_live_...`, JwtSession only claims Bearer JWT shapes + * (with KYTE_JWT_SECRET defined), and Hmac is the catch-all that + * handles every existing customer flow. + * + * Phase 2 (MCP) added McpTokenStrategy. + * Phase 3 (JWT) appended JwtSessionStrategy between MCP and HMAC. + * Each is opt-in by config: a token can only be claimed by a + * strategy that's actually configured on the install. */ public static function buildDefault(): self { return new self([ + new McpTokenStrategy(), + new JwtSessionStrategy(), new HmacSessionStrategy(), ]); } diff --git a/src/Core/Auth/JwtEndpoint.php b/src/Core/Auth/JwtEndpoint.php new file mode 100644 index 00000000..b2d9dd5c --- /dev/null +++ b/src/Core/Auth/JwtEndpoint.php @@ -0,0 +1,369 @@ +} + * Revokes EVERY active refresh token for the + * user whose token was presented. + * + * Routing: Api::route() detects a `/jwt` first segment and dispatches + * here before the normal MVC pipeline runs (same pattern as /mcp). + * + * Multilogon: every /jwt/login creates a NEW token family. Logging in + * from device A and then device B yields two independent families; + * revoking either does not affect the other. This is the modern norm + * and differs from HMAC's ALLOW_MULTILOGON-gated single-session-per-user. + * + * Testability: process() is pure (Api, $_SERVER-shape array, raw body) + * → return ['status' => N, 'body' => [...]]. handle() binds it to PHP + * globals. + */ +final class JwtEndpoint +{ + private const DEFAULT_ACCESS_TTL = 900; + private const DEFAULT_ISSUER = 'kyte'; + + /** + * Production entry point — reads globals, writes to SAPI. + */ + public static function handle(Api $api): void + { + $rawBody = (string)file_get_contents('php://input'); + $result = self::process($api, $_SERVER, $rawBody); + + http_response_code($result['status']); + header('Content-Type: application/json'); + echo json_encode($result['body']); + } + + /** + * Pure dispatcher. Returns ['status' => httpCode, 'body' => array]. + */ + public static function process(Api $api, array $server, string $rawBody): array + { + $method = $server['REQUEST_METHOD'] ?? 'GET'; + $path = ltrim((string)parse_url($server['REQUEST_URI'] ?? '', PHP_URL_PATH), '/'); + $segments = explode('/', $path); + $action = $segments[1] ?? ''; + + if ($method !== 'POST') { + return self::error(405, 'method_not_allowed', 'POST required.'); + } + + $body = self::decodeBody($rawBody); + $ip = self::clientIp($server); + + try { + switch ($action) { + case 'login': return self::login($body, $ip); + case 'refresh': return self::refresh($body, $ip); + case 'logout': return self::logout($body); + case 'logout-all': return self::logoutAll($body); + default: + return self::error(404, 'not_found', "Unknown JWT endpoint: {$action}."); + } + } catch (SessionException $e) { + return self::error(401, 'unauthorized', $e->getMessage()); + } catch (\Throwable $e) { + error_log('JwtEndpoint: ' . $e->getMessage()); + return self::error(500, 'internal_error', 'Internal error.'); + } + } + + private static function login(array $body, string $ip): array + { + $appIdentifier = isset($body['app_identifier']) && $body['app_identifier'] !== '' + ? (string)$body['app_identifier'] + : null; + + $context = self::resolveAuthContext($appIdentifier); + $usernameField = $context['username_field']; + $passwordField = $context['password_field']; + $userModel = $context['user_model']; + $app = $context['app']; + + if (empty($body[$usernameField]) || empty($body[$passwordField])) { + return self::error(400, 'invalid_request', "Missing {$usernameField} or {$passwordField}."); + } + + $user = new ModelObject($userModel); + if (!$user->retrieve($usernameField, $body[$usernameField])) { + // Avoid revealing whether the username exists. + return self::error(401, 'invalid_credentials', 'Invalid credentials.'); + } + + $hashed = $user->{$passwordField} ?? null; + if (!is_string($hashed) || !password_verify((string)$body[$passwordField], $hashed)) { + return self::error(401, 'invalid_credentials', 'Invalid credentials.'); + } + + $accountId = isset($user->kyte_account) ? (int)$user->kyte_account : 0; + if ($accountId === 0) { + return self::error(401, 'invalid_credentials', 'Invalid credentials.'); + } + + $account = new ModelObject(KyteAccount); + if (!$account->retrieve('id', $accountId)) { + return self::error(401, 'invalid_credentials', 'Invalid credentials.'); + } + + $appId = $app !== null ? (int)$app->id : null; + + $accessToken = self::mintAccessJwt($user, $account, $appIdentifier); + $refresh = RefreshTokenStore::issue((int)$user->id, $accountId, $appId, $ip); + + // Best-effort lastLogin update — matches SessionController behavior. + if (isset($userModel['struct']['lastLogin'])) { + try { + $user->save(['lastLogin' => time()]); + } catch (\Throwable $e) { + error_log('JwtEndpoint: lastLogin update failed - ' . $e->getMessage()); + } + } + + return self::success([ + 'access_token' => $accessToken, + 'token_type' => 'Bearer', + 'expires_in' => self::accessTtl(), + 'refresh_token' => $refresh['raw'], + 'refresh_expires_at' => $refresh['expires_at'], + ]); + } + + private static function refresh(array $body, string $ip): array + { + $raw = (string)($body['refresh_token'] ?? ''); + if ($raw === '') { + return self::error(400, 'invalid_request', 'refresh_token required.'); + } + + $result = RefreshTokenStore::rotate($raw, $ip); + + // Re-load user + account for the new access token. + $user = new ModelObject(KyteUser); + if (!$user->retrieve('id', $result['user_id'])) { + // The user's row was removed between issuance and refresh. + // Family revocation is appropriate — the principal is gone. + return self::error(401, 'invalid_credentials', 'Refresh token principal not found.'); + } + + $account = new ModelObject(KyteAccount); + if (!$account->retrieve('id', $result['account_id'])) { + return self::error(401, 'invalid_credentials', 'Refresh token account not found.'); + } + + $appIdentifier = null; + if ($result['app_id'] !== null) { + $app = new ModelObject(Application); + if ($app->retrieve('id', $result['app_id'])) { + $appIdentifier = (string)$app->identifier; + } + } + + $accessToken = self::mintAccessJwt($user, $account, $appIdentifier); + + return self::success([ + 'access_token' => $accessToken, + 'token_type' => 'Bearer', + 'expires_in' => self::accessTtl(), + 'refresh_token' => $result['raw'], + 'refresh_expires_at' => $result['expires_at'], + ]); + } + + private static function logout(array $body): array + { + $raw = (string)($body['refresh_token'] ?? ''); + if ($raw === '') { + return self::error(400, 'invalid_request', 'refresh_token required.'); + } + // Idempotent — already-revoked tokens still return ok. + RefreshTokenStore::revokeByToken($raw, 'logout'); + return self::success(['ok' => true]); + } + + private static function logoutAll(array $body): array + { + $raw = (string)($body['refresh_token'] ?? ''); + if ($raw === '') { + return self::error(400, 'invalid_request', 'refresh_token required.'); + } + + // Resolve the user/account from the presented token first. We + // don't require the token to still be active — even an expired + // refresh token's owner can request a global logout. + $hash = hash('sha256', $raw); + $token = new ModelObject(KyteRefreshToken); + if (!$token->retrieve('token_hash', $hash)) { + return self::error(401, 'unauthorized', 'Refresh token not recognized.'); + } + + $count = RefreshTokenStore::revokeAllForUser( + (int)$token->user, + (int)$token->kyte_account, + 'logout_all' + ); + return self::success(['ok' => true, 'revoked' => $count]); + } + + /** + * Mint a fresh access JWT. Claims: + * iss KYTE_JWT_ISSUER (default 'kyte') + * sub user id + * aud account id (as int) + * exp now + KYTE_JWT_ACCESS_TTL (default 900s) + * nbf now + * iat now + * jti random per-token id + * email user email (if available) + * app application identifier when app-scoped, omitted otherwise + */ + private static function mintAccessJwt(ModelObject $user, ModelObject $account, ?string $appIdentifier): string + { + if (!defined('KYTE_JWT_SECRET') || KYTE_JWT_SECRET === '') { + throw new \RuntimeException('KYTE_JWT_SECRET is not configured.'); + } + + $now = time(); + $payload = [ + 'iss' => defined('KYTE_JWT_ISSUER') ? KYTE_JWT_ISSUER : self::DEFAULT_ISSUER, + 'sub' => (int)$user->id, + 'aud' => (int)$account->id, + 'iat' => $now, + 'nbf' => $now, + 'exp' => $now + self::accessTtl(), + 'jti' => bin2hex(random_bytes(8)), + ]; + if (isset($user->email)) { + $payload['email'] = (string)$user->email; + } + if ($appIdentifier !== null) { + $payload['app'] = $appIdentifier; + } + return JWT::encode($payload, KYTE_JWT_SECRET, 'HS256'); + } + + /** + * Pick the right (user_model, username_field, password_field) tuple + * for the optional app_identifier in the login request. When the app + * declares its own user model, use that; otherwise default to + * KyteUser + USERNAME_FIELD / PASSWORD_FIELD constants. + * + * @return array{user_model: array, username_field: string, password_field: string, app: ?ModelObject} + */ + private static function resolveAuthContext(?string $appIdentifier): array + { + $defaultUserField = defined('USERNAME_FIELD') ? USERNAME_FIELD : 'email'; + $defaultPassField = defined('PASSWORD_FIELD') ? PASSWORD_FIELD : 'password'; + + if ($appIdentifier === null) { + return [ + 'user_model' => KyteUser, + 'username_field' => $defaultUserField, + 'password_field' => $defaultPassField, + 'app' => null, + ]; + } + + $app = new ModelObject(Application); + if (!$app->retrieve('identifier', $appIdentifier)) { + // Unknown app — fall back to default user model. Login will + // still fail at password_verify if the credentials don't + // match a KyteUser, which is the safer surface. + return [ + 'user_model' => KyteUser, + 'username_field' => $defaultUserField, + 'password_field' => $defaultPassField, + 'app' => null, + ]; + } + + if ($app->user_model !== null && $app->username_colname !== null && $app->password_colname !== null) { + return [ + 'user_model' => constant($app->user_model), + 'username_field' => (string)$app->username_colname, + 'password_field' => (string)$app->password_colname, + 'app' => $app, + ]; + } + + return [ + 'user_model' => KyteUser, + 'username_field' => $defaultUserField, + 'password_field' => $defaultPassField, + 'app' => $app, + ]; + } + + private static function accessTtl(): int + { + return defined('KYTE_JWT_ACCESS_TTL') ? (int)KYTE_JWT_ACCESS_TTL : self::DEFAULT_ACCESS_TTL; + } + + private static function decodeBody(string $rawBody): array + { + if ($rawBody === '') { + return []; + } + $decoded = json_decode($rawBody, true); + return is_array($decoded) ? $decoded : []; + } + + private static function clientIp(array $server): string + { + // Mirror the resolution policy used by Kyte\Mcp\Util\ClientIp, + // adapted to take an explicit $server array (the MCP helper + // reads $_SERVER directly, which we can't do from process() + // without breaking testability). + if (defined('KYTE_TRUST_PROXY_IP_HEADERS') && KYTE_TRUST_PROXY_IP_HEADERS) { + $cf = $server['HTTP_CF_CONNECTING_IP'] ?? null; + if (is_string($cf) && $cf !== '') { + return $cf; + } + $xff = $server['HTTP_X_FORWARDED_FOR'] ?? null; + if (is_string($xff) && $xff !== '') { + $firstHop = trim(explode(',', $xff)[0]); + if ($firstHop !== '') { + return $firstHop; + } + } + } + return (string)($server['REMOTE_ADDR'] ?? ''); + } + + private static function success(array $body): array + { + return ['status' => 200, 'body' => $body]; + } + + private static function error(int $status, string $code, string $message): array + { + return [ + 'status' => $status, + 'body' => ['error' => $code, 'message' => $message], + ]; + } +} diff --git a/src/Core/Auth/JwtSessionStrategy.php b/src/Core/Auth/JwtSessionStrategy.php new file mode 100644 index 00000000..7fc00ed7 --- /dev/null +++ b/src/Core/Auth/JwtSessionStrategy.php @@ -0,0 +1,260 @@ +` + * header whose body starts with `eyJ` (a JWT-style base64url first segment). + * It does NOT claim `kmcp_live_` MCP bearer tokens — those start with + * 'kmcp_' so the prefix check is unambiguous. + * + * Verification (preAuth): + * - Decode + verify signature using KYTE_JWT_SECRET. + * - Confirm exp not in the past (firebase/php-jwt enforces). + * - Confirm iss matches expected issuer. + * - Resolve sub → user, aud → account, app (if present) → application. + * - Populate $api->user, $api->account, $api->app, $api->session. + * - 401 SessionException on any failure. + * + * Refresh tokens are NOT JWTs. They live in KyteRefreshToken (separate + * commit) and are exchanged at /jwt/refresh for a new (access, refresh) + * pair with reuse detection. The strategy here only validates access + * tokens — refresh token handling is in the endpoint controller. + * + * Config constants (define in config.php on each install): + * KYTE_JWT_SECRET Required. HS256 signing key. Should be at least + * 256 bits of entropy. NEVER commit to version + * control. + * KYTE_JWT_ISSUER Optional. Defaults to 'kyte'. Mismatched tokens + * are rejected at preAuth time. + * KYTE_JWT_ACCESS_TTL Optional. Seconds. Defaults to 900 (15 min). + * KYTE_JWT_REFRESH_TTL Optional. Seconds. Used by /jwt/refresh code, + * not by this strategy. Defaults to 604800 (7d). + * + * See docs/design/kyte-mcp-and-auth-migration.md section 6, Phase 3. + */ +class JwtSessionStrategy implements AuthStrategy +{ + /** Returned by name() — used in shadow-mode telemetry rows. */ + public const NAME = 'jwt_session'; + + private const ALGO = 'HS256'; + + private const DEFAULT_ISSUER = 'kyte'; + + private const DEFAULT_ACCESS_TTL = 900; + + /** + * Inspect the Authorization header. We claim any Bearer token that + * looks like a JWT — three base64url segments separated by '.' — + * but not the kmcp_live_ prefix used by McpTokenStrategy. + */ + public function matches(): bool + { + if (!defined('KYTE_JWT_SECRET') || KYTE_JWT_SECRET === '') { + // JWT support not configured on this install. + return false; + } + + $header = $this->authorizationHeader(); + if ($header === null) { + return false; + } + + // Strict prefix: 'Bearer ' followed by an opaque token. + if (stripos($header, 'Bearer ') !== 0) { + return false; + } + + $token = trim(substr($header, 7)); + if ($token === '') { + return false; + } + + // MCP tokens are kmcp_live_... — let McpTokenStrategy claim those. + if (stripos($token, 'kmcp_') === 0) { + return false; + } + + // A JWT has three dot-separated base64url segments. The first + // segment decodes to JSON like {"alg":"HS256","typ":"JWT"}. + // We don't decode here — that's preAuth's job — we just check + // the structural shape so a Bearer that happens to be some + // other opaque token isn't claimed. + $segments = explode('.', $token); + if (count($segments) !== 3) { + return false; + } + + // First segment must start with 'eyJ' (the base64url encoding + // of '{"' which begins every JWT header). + return strncmp($segments[0], 'eyJ', 3) === 0; + } + + /** + * Decode the bearer JWT, verify the signature, and populate Api state. + */ + public function preAuth(Api $api): void + { + $token = $this->extractToken(); + if ($token === null) { + throw new SessionException('Missing JWT bearer token.'); + } + + try { + $decoded = JWT::decode($token, new Key(KYTE_JWT_SECRET, self::ALGO)); + } catch (\Throwable $e) { + // Catches: SignatureInvalidException, BeforeValidException, + // ExpiredException, UnexpectedValueException, etc. + throw new SessionException('Invalid or expired JWT: ' . $e->getMessage()); + } + + $expectedIssuer = defined('KYTE_JWT_ISSUER') ? KYTE_JWT_ISSUER : self::DEFAULT_ISSUER; + if (!isset($decoded->iss) || $decoded->iss !== $expectedIssuer) { + throw new SessionException('JWT issuer mismatch.'); + } + + if (!isset($decoded->sub, $decoded->aud)) { + throw new SessionException('JWT missing required claims (sub/aud).'); + } + + // Resolve account from aud claim. + $account = new ModelObject(KyteAccount); + if (!$account->retrieve('id', (int)$decoded->aud)) { + throw new SessionException('JWT account not found.'); + } + $api->account = $account; + + // Resolve user. For app-scoped tokens, $api->app->user_model is + // the right table; otherwise it's KyteUser. Mirrors the pattern + // in Api::route(). + $appIdentifier = $decoded->app ?? null; + if ($appIdentifier !== null) { + $app = new ModelObject(Application); + if (!$app->retrieve('identifier', $appIdentifier)) { + throw new SessionException('JWT application not found.'); + } + if ((int)$app->kyte_account !== (int)$account->id) { + throw new SessionException('JWT application/account mismatch.'); + } + $api->app = $app; + $api->appId = $appIdentifier; + + $userModel = $app->user_model !== null ? constant($app->user_model) : KyteUser; + } else { + $userModel = KyteUser; + } + + $user = new ModelObject($userModel); + if (!$user->retrieve('id', (int)$decoded->sub)) { + throw new SessionException('JWT user not found.'); + } + if (isset($user->kyte_account) && (int)$user->kyte_account !== (int)$account->id) { + throw new SessionException('JWT user/account mismatch.'); + } + $api->user = $user; + + // Mark the SessionManager as authenticated. + // + // ModelController::authenticate() (line ~189) gates every protected + // model endpoint on `$this->api->session->hasSession`. In the HMAC + // flow, $api->session->validate() sets this to true after consuming + // a valid session cookie. JWT has no cookie-backed session — we + // just validated the bearer above and resolved $api->user — so we + // mark hasSession directly here. Without this, every JWT bearer + // request to a protected MVC endpoint returns 403 "Unauthorized + // API request." even though the JWT itself decoded cleanly. + if (isset($api->session) && property_exists($api->session, 'hasSession')) { + $api->session->hasSession = true; + } + + // The HMAC path uses $api->session (SessionManager) to hand out + // rotating tokens. JWT has no rotating-session state; we set + // the response slot to indicate the session is JWT-mediated so + // ActivityLogger and others can tell. + $api->response['session'] = 'jwt'; + $api->response['uid'] = (int)$user->id; + } + + /** + * Phase 2 of the contract. JWT verification is fully completed in + * preAuth — there's no second-pass signature step like HMAC. This + * is a no-op. + */ + public function verify(Api $api): void + { + // Intentionally empty. See class docblock. + } + + public function name(): string + { + return self::NAME; + } + + /** + * Return the Authorization header value or null. + * + * Apache sometimes hides the Authorization header on PHP-FPM unless + * a RewriteRule sets HTTP_AUTHORIZATION. We check both common + * surfaces. apache_request_headers() is a third fallback when + * available. + */ + private function authorizationHeader(): ?string + { + $candidates = [ + $_SERVER['HTTP_AUTHORIZATION'] ?? null, + $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? null, + ]; + + foreach ($candidates as $value) { + if (is_string($value) && $value !== '') { + return $value; + } + } + + if (\function_exists('apache_request_headers')) { + $headers = \apache_request_headers(); + if (is_array($headers)) { + foreach ($headers as $name => $value) { + if (strcasecmp($name, 'Authorization') === 0 && is_string($value) && $value !== '') { + return $value; + } + } + } + } + + return null; + } + + /** + * Return just the token portion of the Authorization header, or null + * if no JWT bearer token is present. + */ + private function extractToken(): ?string + { + $header = $this->authorizationHeader(); + if ($header === null || stripos($header, 'Bearer ') !== 0) { + return null; + } + $token = trim(substr($header, 7)); + return $token === '' ? null : $token; + } +} diff --git a/src/Core/Auth/McpTokenStrategy.php b/src/Core/Auth/McpTokenStrategy.php new file mode 100644 index 00000000..c5c9782a --- /dev/null +++ b/src/Core/Auth/McpTokenStrategy.php @@ -0,0 +1,250 @@ +account + * from the token's FK so downstream controllers run under the correct tenancy. + * + * Intentionally does NOT populate $api->key. MCP tokens are not API keys — + * they're a separate credential scheme. Any code path that still references + * $api->key inside an MCP flow should be considered a mis-route (MCP tools + * operate on account-scoped models directly). + * + * Scope enforcement is NOT performed here. matches()/preAuth() answer + * "is this caller authenticated at all"; whether the caller may invoke a + * specific tool is a decision the /mcp endpoint makes by reading + * $strategy->scopes. Splitting auth from authorization this way matches the + * MCP spec's JSON-RPC dispatch model. + * + * NOT yet registered in AuthDispatcher::buildDefault(). Real registration + * happens alongside the /mcp endpoint in a later Phase 2 commit — once routed, + * this strategy becomes live on any install with AUTH_STRATEGY_DISPATCHER='on' + * (currently 'shadow' on dev, 'off' at customers). + */ +class McpTokenStrategy implements AuthStrategy +{ + public const TOKEN_PREFIX = 'kmcp_live_'; + + /** @var \Kyte\Core\ModelObject|null Set by preAuth() on success. */ + public $token = null; + + /** @var string[] Parsed scopes from the validated token. Empty until preAuth() succeeds. */ + public $scopes = []; + + public function name(): string + { + return 'mcp_token'; + } + + /** + * True iff the request carries `Authorization: Bearer ...`. + * + * Checks both HTTP_AUTHORIZATION and REDIRECT_HTTP_AUTHORIZATION because + * Apache strips the Authorization header from CGI/FPM by default; customers + * who haven't added `SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1` + * or the equivalent mod_rewrite rule will see it only in REDIRECT_*. + * Kyte's default .htaccess handles this on existing installs, but new + * installs or custom vhosts may not — accept both to avoid a confusing + * "token rejected" debugging session. + */ + public function matches(): bool + { + $raw = $this->rawToken(); + return $raw !== null && str_starts_with($raw, self::TOKEN_PREFIX); + } + + /** + * Validates the token and populates $api->account. See class docblock for + * what is intentionally left out of this path (API key, app binding, scope + * check). + */ + public function preAuth(Api $api): void + { + $raw = $this->rawToken(); + if ($raw === null || !str_starts_with($raw, self::TOKEN_PREFIX)) { + throw new SessionException('[ERROR] MCP bearer token missing or malformed.'); + } + + $hash = hash('sha256', $raw); + + $token = new \Kyte\Core\ModelObject(KyteMCPToken); + if (!$token->retrieve('token_hash', $hash)) { + throw new SessionException('[ERROR] Invalid MCP token.'); + } + + if ((int)$token->revoked_at !== 0) { + throw new SessionException('[ERROR] MCP token has been revoked.'); + } + + $now = time(); + if ((int)$token->expires_at !== 0 && $now > (int)$token->expires_at) { + throw new SessionException('[ERROR] MCP token has expired.'); + } + + $allowlist = trim((string)$token->ip_allowlist); + if ($allowlist !== '' && !self::ipAllowed($this->clientIp(), $allowlist)) { + throw new SessionException('[ERROR] MCP token not permitted from this source IP.'); + } + + if (!$api->account->retrieve('id', (int)$token->kyte_account)) { + throw new SessionException("[ERROR] MCP token's owning account no longer exists."); + } + + $this->token = $token; + $this->scopes = array_values(array_filter(array_map('trim', explode(',', (string)$token->scopes)))); + + // Synchronous audit-trail update. One UPDATE per request is cheap at + // per-tenant MCP volumes (1–10 concurrent developers). If this becomes + // hot in telemetry, revisit as a background write. + $token->save([ + 'last_used_at' => $now, + 'last_used_ip' => $this->clientIp(), + ]); + + // MCP_TOKEN_USE audit row per design doc R7. Best-effort — the auth + // decision still stands if the log write fails. The token's id goes + // in record_id (not request_data) to dodge the redactSensitive + // "token" substring match that would blank a token_id field. + try { + \Kyte\Core\ActivityLogger::getInstance()->log( + 'MCP_TOKEN_USE', + 'KyteMCPToken', + 'token_prefix', + (string)$token->token_prefix, + [ + 'scopes' => $this->scopes, + 'ip' => $this->clientIp(), + ], + 200, + 'authenticated', + null, + (int)$token->id + ); + } catch (\Throwable $e) { + error_log('McpTokenStrategy: failed to log MCP_TOKEN_USE - ' . $e->getMessage()); + } + } + + /** + * No-op. Bearer tokens complete their validation in preAuth(); the + * verify() slot exists only to satisfy the two-phase AuthStrategy contract + * (mirroring HmacSessionStrategy's preAuth/verify split). + */ + public function verify(Api $api): void + { + } + + /** + * Extract the raw bearer token from whichever Authorization header slot + * the SAPI exposed. Returns null if no valid Bearer header is present. + */ + private function rawToken(): ?string + { + $header = $_SERVER['HTTP_AUTHORIZATION'] + ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] + ?? null; + + if ($header === null) { + return null; + } + + if (stripos($header, 'Bearer ') !== 0) { + return null; + } + + $token = trim(substr($header, 7)); + return $token === '' ? null : $token; + } + + /** + * Real client IP for IP allowlist + audit fields. When the install + * sits behind Cloudflare/an LB, REMOTE_ADDR is the proxy's edge IP + * — useless for the allowlist and misleading in audit rows. The + * helper resolves the real client IP behind a per-install trust + * gate (KYTE_TRUST_PROXY_IP_HEADERS). See Kyte\Mcp\Util\ClientIp + * for the resolution policy and security rationale. + */ + private function clientIp(): string + { + return \Kyte\Mcp\Util\ClientIp::resolve(); + } + + /** + * Checks an IP against a comma-separated CIDR allowlist. Supports IPv4 and + * IPv6. Bare addresses are treated as /32 (v4) or /128 (v6). Returns false + * if the client IP is empty (unknowable source → deny when allowlist is set). + */ + private static function ipAllowed(string $client, string $allowlist): bool + { + if ($client === '') { + return false; + } + + $clientPacked = @inet_pton($client); + if ($clientPacked === false) { + return false; + } + + foreach (explode(',', $allowlist) as $cidr) { + $cidr = trim($cidr); + if ($cidr === '') { + continue; + } + + if (self::cidrMatch($clientPacked, $cidr)) { + return true; + } + } + return false; + } + + private static function cidrMatch(string $clientPacked, string $cidr): bool + { + $slash = strpos($cidr, '/'); + if ($slash === false) { + $network = $cidr; + $prefix = null; + } else { + $network = substr($cidr, 0, $slash); + $prefix = (int)substr($cidr, $slash + 1); + } + + $networkPacked = @inet_pton($network); + if ($networkPacked === false) { + return false; + } + + if (strlen($networkPacked) !== strlen($clientPacked)) { + return false; // v4/v6 mismatch + } + + $fullBits = strlen($networkPacked) * 8; + if ($prefix === null) { + $prefix = $fullBits; + } + if ($prefix < 0 || $prefix > $fullBits) { + return false; + } + + $fullBytes = intdiv($prefix, 8); + $remainder = $prefix % 8; + + if ($fullBytes > 0 && substr($networkPacked, 0, $fullBytes) !== substr($clientPacked, 0, $fullBytes)) { + return false; + } + + if ($remainder === 0) { + return true; + } + + $mask = chr((0xFF << (8 - $remainder)) & 0xFF); + return (ord($networkPacked[$fullBytes]) & ord($mask)) === (ord($clientPacked[$fullBytes]) & ord($mask)); + } +} diff --git a/src/Core/Auth/RefreshTokenStore.php b/src/Core/Auth/RefreshTokenStore.php new file mode 100644 index 00000000..687ac79f --- /dev/null +++ b/src/Core/Auth/RefreshTokenStore.php @@ -0,0 +1,208 @@ +retrieve('token_hash', $hash)) { + throw new SessionException('Refresh token not recognized.'); + } + + // Reuse detection: a revoked token presented again is a leak signal. + if ((int)($token->revoked_at ?? 0) !== 0) { + self::revokeFamily((string)$token->token_family, 'reuse_detected'); + throw new SessionException('Refresh token reuse detected; all sessions in this chain have been revoked.'); + } + + // Expiration check (expired-but-not-revoked path). + $now = time(); + if ((int)$token->expires_at !== 0 && (int)$token->expires_at < $now) { + $token->save([ + 'revoked_at' => $now, + 'revoked_reason' => 'expired', + ]); + throw new SessionException('Refresh token expired.'); + } + + // Normal rotation: issue successor with same family, then mark + // current revoked with rotated_to = successor id. + $userId = (int)$token->user; + $accountId = (int)$token->kyte_account; + $appId = $token->application !== null ? (int)$token->application : null; + $family = (string)$token->token_family; + + $successor = self::issueInFamily($userId, $accountId, $appId, $family, $ip); + + $token->save([ + 'revoked_at' => $now, + 'revoked_reason' => 'rotated', + 'rotated_to' => $successor['id'], + 'last_used_at' => $now, + 'last_used_ip' => $ip !== '' ? $ip : null, + ]); + + return array_merge($successor, [ + 'user_id' => $userId, + 'account_id' => $accountId, + 'app_id' => $appId, + ]); + } + + /** + * Revoke a single refresh token. Idempotent — already-revoked tokens + * are left unchanged. Used by /jwt/logout to invalidate just the + * presented session. + */ + public static function revokeByToken(string $rawToken, string $reason): bool + { + $hash = hash('sha256', $rawToken); + $token = new ModelObject(KyteRefreshToken); + if (!$token->retrieve('token_hash', $hash)) { + return false; + } + if ((int)($token->revoked_at ?? 0) !== 0) { + return true; // already revoked, idempotent + } + $token->save([ + 'revoked_at' => time(), + 'revoked_reason' => $reason, + ]); + return true; + } + + /** + * Revoke every active token in a family. Returns the count of + * tokens revoked. Used by reuse detection and by family-level admin + * revocation. + */ + public static function revokeFamily(string $family, string $reason): int + { + $model = new Model(KyteRefreshToken); + $model->retrieve('token_family', $family, false, [ + ['field' => 'revoked_at', 'value' => 0], + ]); + $now = time(); + $count = 0; + foreach ($model->objects as $t) { + $t->save([ + 'revoked_at' => $now, + 'revoked_reason' => $reason, + ]); + $count++; + } + return $count; + } + + /** + * Revoke every active refresh token for a user. Used by /jwt/logout-all + * to terminate all sessions across all devices. + */ + public static function revokeAllForUser(int $userId, int $accountId, string $reason = 'logout_all'): int + { + $model = new Model(KyteRefreshToken); + $model->retrieve('user', $userId, false, [ + ['field' => 'kyte_account', 'value' => $accountId], + ['field' => 'revoked_at', 'value' => 0], + ]); + $now = time(); + $count = 0; + foreach ($model->objects as $t) { + $t->save([ + 'revoked_at' => $now, + 'revoked_reason' => $reason, + ]); + $count++; + } + return $count; + } + + /** + * Internal: create a refresh token row in a specific family. + * Used by both issue() (new family) and rotate() (existing family). + */ + private static function issueInFamily(int $userId, int $accountId, ?int $appId, string $family, string $ip): array + { + $raw = self::PREFIX . self::randomTokenBody(); + $hash = hash('sha256', $raw); + $ttl = defined('KYTE_JWT_REFRESH_TTL') ? (int)KYTE_JWT_REFRESH_TTL : self::DEFAULT_REFRESH_TTL; + $now = time(); + $expiresAt = $now + $ttl; + + $token = new ModelObject(KyteRefreshToken); + $token->create([ + 'token_hash' => $hash, + 'token_prefix' => substr($raw, 0, 24), + 'token_family' => $family, + 'user' => $userId, + 'application' => $appId, + 'expires_at' => $expiresAt, + 'last_used_at' => $now, + 'last_used_ip' => $ip !== '' ? $ip : null, + 'kyte_account' => $accountId, + ]); + + return [ + 'raw' => $raw, + 'id' => (int)$token->id, + 'family' => $family, + 'expires_at' => $expiresAt, + ]; + } + + /** + * Base64url-encoded 32 random bytes — RFC 4648 §5 alphabet, no + * padding. URL-safe, unambiguous, ~43 chars. + */ + private static function randomTokenBody(): string + { + return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + } +} diff --git a/src/Core/SensitivityPolicy.php b/src/Core/SensitivityPolicy.php new file mode 100644 index 00000000..25d19d72 --- /dev/null +++ b/src/Core/SensitivityPolicy.php @@ -0,0 +1,234 @@ + */ + private array $controllerCache = []; + + /** @var array */ + private array $modelCache = []; + + /** @var array> */ + private array $fieldsCache = []; + + private function __construct() {} + + public static function getInstance(): self + { + return self::$instance ??= new self(); + } + + /** + * Drop the in-process cache. Test-only — production code should not + * call this; the singleton is per-request and the request boundary + * is the natural reset. + */ + public static function resetForTests(): void + { + self::$instance = null; + } + + /** + * Is this controller flagged sensitive? + * + * Returns false for null/empty controller name, null account id, + * a controller that doesn't exist or belongs to a different account, + * or any DB lookup failure. The fail-permissive default is deliberate: + * the hardcoded SENSITIVE_FIELDS baseline in ActivityLogger still + * runs, so degraded mode matches current behavior. + */ + public function isControllerSensitive(?string $controllerName, ?int $accountId): bool + { + if (!$controllerName || $accountId === null) { + return false; + } + $key = $controllerName . '|' . $accountId; + if (\array_key_exists($key, $this->controllerCache)) { + return $this->controllerCache[$key]; + } + if (!\defined('Controller')) { + return $this->controllerCache[$key] = false; + } + try { + $c = new ModelObject(\Controller); + if ($c->retrieve('name', $controllerName) && (int)$c->kyte_account === $accountId) { + return $this->controllerCache[$key] = ((int)($c->sensitive ?? 0) === 1); + } + return $this->controllerCache[$key] = false; + } catch (\Throwable $e) { + error_log('SensitivityPolicy: controller lookup failed for ' . $controllerName . ' - ' . $e->getMessage()); + return false; + } + } + + /** + * Is this data model flagged sensitive? + * + * Same failure semantics as isControllerSensitive. + */ + public function isModelSensitive(?string $modelName, ?int $accountId): bool + { + if (!$modelName || $accountId === null) { + return false; + } + $key = $modelName . '|' . $accountId; + if (\array_key_exists($key, $this->modelCache)) { + return $this->modelCache[$key]; + } + if (!\defined('DataModel')) { + return $this->modelCache[$key] = false; + } + try { + $dm = new ModelObject(\DataModel); + if ($dm->retrieve('name', $modelName) && (int)$dm->kyte_account === $accountId) { + return $this->modelCache[$key] = ((int)($dm->sensitive ?? 0) === 1); + } + return $this->modelCache[$key] = false; + } catch (\Throwable $e) { + error_log('SensitivityPolicy: model lookup failed for ' . $modelName . ' - ' . $e->getMessage()); + return false; + } + } + + /** + * Return the set of sensitive field names for a model. + * + * Empty array when nothing is flagged, when the model is unknown, + * or when the lookup fails. Names are returned as stored (the + * redactFields() caller does case-insensitive matching). + * + * @return array + */ + public function getSensitiveFields(?string $modelName, ?int $accountId): array + { + if (!$modelName || $accountId === null) { + return []; + } + $key = $modelName . '|' . $accountId; + if (\array_key_exists($key, $this->fieldsCache)) { + return $this->fieldsCache[$key]; + } + if (!\defined('DataModel') || !\defined('ModelAttribute')) { + return $this->fieldsCache[$key] = []; + } + try { + $dm = new ModelObject(\DataModel); + if (!$dm->retrieve('name', $modelName) || (int)$dm->kyte_account !== $accountId) { + return $this->fieldsCache[$key] = []; + } + $attrs = new Model(\ModelAttribute); + $attrs->retrieve('dataModel', (int)$dm->id, false, [ + ['field' => 'kyte_account', 'value' => $accountId], + ['field' => 'sensitive', 'value' => 1], + ]); + $fields = []; + foreach ($attrs->objects as $a) { + if (!empty($a->name)) { + $fields[] = (string)$a->name; + } + } + return $this->fieldsCache[$key] = $fields; + } catch (\Throwable $e) { + error_log('SensitivityPolicy: field lookup failed for ' . $modelName . ' - ' . $e->getMessage()); + return []; + } + } + + /** + * Should the entire request body / response payload be dropped from logs? + * + * True if either the controller or the model is blanket-sensitive. + * Field-level redaction is a separate concern handled by redactFields(). + */ + public function shouldDropPayload(?string $controllerName, ?string $modelName, ?int $accountId): bool + { + return $this->isControllerSensitive($controllerName, $accountId) + || $this->isModelSensitive($modelName, $accountId); + } + + /** + * Apply per-field redaction to a payload using ModelAttribute.sensitive flags. + * + * Sensitive field names get replaced with '[REDACTED]'. Other values + * pass through. Nested arrays recurse under the same model context + * (no attempt is made to switch model context on FK boundaries — the + * caller knows the top-level model). + * + * When the model is unknown (virtual controllers) this is a no-op; + * the caller should have invoked shouldDropPayload() first for that + * tier of protection. + */ + public function redactFields(mixed $data, ?string $modelName, ?int $accountId): mixed + { + if (!\is_array($data) || $modelName === null) { + return $data; + } + $sensitive = $this->getSensitiveFields($modelName, $accountId); + if (empty($sensitive)) { + return $data; + } + $sensitiveLower = \array_map('strtolower', $sensitive); + $out = []; + foreach ($data as $k => $v) { + if (\is_string($k) && \in_array(\strtolower($k), $sensitiveLower, true)) { + $out[$k] = '[REDACTED]'; + } elseif (\is_array($v)) { + $out[$k] = $this->redactFields($v, $modelName, $accountId); + } else { + $out[$k] = $v; + } + } + return $out; + } +} diff --git a/src/Exception/ErrorHandler.php b/src/Exception/ErrorHandler.php index 55959fd6..5ad2d8d8 100644 --- a/src/Exception/ErrorHandler.php +++ b/src/Exception/ErrorHandler.php @@ -46,6 +46,15 @@ public function getRequestId() { public function handleException($exception) { $error = new \Kyte\Core\ModelObject(KyteError); + $modelName = isset($this->apiContext->model) ? $this->apiContext->model : null; + $accountId = isset($this->apiContext->account->id) ? (int)$this->apiContext->account->id : null; + [$dataForLog, $responseForLog, $skipAI] = $this->resolveSensitivePayload( + isset($this->apiContext->data) ? $this->apiContext->data : null, + isset($this->apiContext->response) ? $this->apiContext->response : null, + $modelName, + $accountId + ); + $log_detail = [ 'kyte_account' => isset($this->apiContext->account->id) ? $this->apiContext->account->id : null, 'user_id' => isset($this->apiContext->user->id) ? $this->apiContext->user->id : null, @@ -55,11 +64,11 @@ public function handleException($exception) { 'signature' => isset($this->apiContext->signature) ? $this->apiContext->signature : null, 'contentType' => isset($this->apiContext->contentType) ? $this->apiContext->contentType : null, 'request' => isset($this->apiContext->request) ? $this->apiContext->request : null, - 'model' => isset($this->apiContext->model) ? $this->apiContext->model : null, + 'model' => $modelName, 'field' => isset($this->apiContext->field) ? $this->apiContext->field : null, 'value' => isset($this->apiContext->value) ? $this->apiContext->value : null, - 'data' => isset($this->apiContext->data) ? print_r($this->apiContext->data, true) : null, - 'response' => isset($this->apiContext->response) ? print_r($this->apiContext->response, true) : null, + 'data' => $dataForLog, + 'response' => $responseForLog, // Exception details 'message' => $exception->getMessage(), 'line' => $exception->getLine(), @@ -90,8 +99,11 @@ public function handleException($exception) { $this->sendSlackNotification($this->apiContext->app->slack_error_webhook, $slackMessage); } - // AI Error Correction - Queue for analysis (non-blocking, async) - if (defined('AI_ERROR_CORRECTION') && AI_ERROR_CORRECTION) { + // AI Error Correction - Queue for analysis (non-blocking, async). + // Skipped entirely when the originating controller, model, or any + // model field is flagged sensitive — do not send regulated data + // off-platform for analysis, regardless of upstream regex masking. + if (defined('AI_ERROR_CORRECTION') && AI_ERROR_CORRECTION && !$skipAI) { \Kyte\AI\AIErrorCorrection::queueForAnalysis($error, $this->apiContext); } } @@ -110,13 +122,20 @@ public function handleError($errno, $errstr, $errfile, $errline) { $error = new \Kyte\Core\ModelObject(KyteError); + $modelName = isset($this->apiContext->model) ? $this->apiContext->model : null; + $accountId = isset($this->apiContext->account->id) ? (int)$this->apiContext->account->id : null; + // handleError doesn't capture data/response by default — only the + // AI-gate flag matters here, but we still call resolve for the + // single source of truth on the skipAI decision. + [, , $skipAI] = $this->resolveSensitivePayload(null, null, $modelName, $accountId); + $log_detail = [ 'kyte_account' => isset($this->apiContext->account->id) ? $this->apiContext->account->id : null, 'user_id' => isset($this->apiContext->user->id) ? $this->apiContext->user->id : null, 'app_id' => isset($this->apiContext->appId) ? $this->apiContext->appId : null, 'api_key' => isset($this->apiContext->key) ? $this->apiContext->key : null, 'request' => isset($this->apiContext->request) ? $this->apiContext->request : null, - 'model' => isset($this->apiContext->model) ? $this->apiContext->model : null, + 'model' => $modelName, 'message' => $errstr, 'file' => $errfile, 'line' => $errline, @@ -147,14 +166,56 @@ public function handleError($errno, $errstr, $errfile, $errline) { } } - // AI Error Correction - Queue for analysis (non-blocking, async) - if (defined('AI_ERROR_CORRECTION') && AI_ERROR_CORRECTION) { + // AI Error Correction - Queue for analysis (non-blocking, async). + // Skipped when the originating controller/model is flagged sensitive. + if (defined('AI_ERROR_CORRECTION') && AI_ERROR_CORRECTION && !$skipAI) { \Kyte\AI\AIErrorCorrection::queueForAnalysis($error, $this->apiContext); } return true; // Don't execute PHP internal error handler } + /** + * Resolve what data/response to persist for a given sensitivity tier + * and whether the AI error-correction queue should be skipped. + * + * Returns [dataForLog, responseForLog, skipAI]: + * - dataForLog / responseForLog are null when the controller or model + * is blanket-sensitive, or otherwise contain the (possibly + * field-redacted) printable representation of the original value. + * - skipAI is true whenever ANY tier is sensitive (controller, model, + * or any field on the model). The AI gate is deliberately wider + * than the storage gate: a partially-redacted payload still + * contains contextual hints we don't want sent off-platform for + * analysis. + */ + private function resolveSensitivePayload($rawData, $rawResponse, ?string $modelName, ?int $accountId): array + { + $policy = \Kyte\Core\SensitivityPolicy::getInstance(); + $shouldDrop = $policy->shouldDropPayload($modelName, $modelName, $accountId); + + $sensitiveFields = $modelName !== null + ? $policy->getSensitiveFields($modelName, $accountId) + : []; + $skipAI = $shouldDrop || !empty($sensitiveFields); + + if ($shouldDrop) { + return [null, null, $skipAI]; + } + + $redactedData = \is_array($rawData) + ? $policy->redactFields($rawData, $modelName, $accountId) + : $rawData; + $redactedResponse = \is_array($rawResponse) + ? $policy->redactFields($rawResponse, $modelName, $accountId) + : $rawResponse; + + $dataForLog = $redactedData !== null ? print_r($redactedData, true) : null; + $responseForLog = $redactedResponse !== null ? print_r($redactedResponse, true) : null; + + return [$dataForLog, $responseForLog, $skipAI]; + } + /** * Check if error should be logged based on LOG_LEVEL configuration */ @@ -295,21 +356,31 @@ public function outputBufferCallback($buffer) { if (strlen($buffer) > $threshold) { $error = new \Kyte\Core\ModelObject(KyteError); + $modelName = isset($this->apiContext->model) ? $this->apiContext->model : null; + $accountId = isset($this->apiContext->account->id) ? (int)$this->apiContext->account->id : null; + $shouldDrop = \Kyte\Core\SensitivityPolicy::getInstance() + ->shouldDropPayload($modelName, $modelName, $accountId); + $log_detail = [ 'kyte_account' => isset($this->apiContext->account->id) ? $this->apiContext->account->id : null, 'user_id' => isset($this->apiContext->user->id) ? $this->apiContext->user->id : null, 'app_id' => isset($this->apiContext->appId) ? $this->apiContext->appId : null, 'request' => isset($this->apiContext->request) ? $this->apiContext->request : null, - 'model' => isset($this->apiContext->model) ? $this->apiContext->model : null, + 'model' => $modelName, 'message' => 'Unexpected output captured via output buffering', 'log_level' => 'warning', 'log_type' => (isset($this->apiContext->appId) && strlen($this->apiContext->appId) > 0) ? 'application' : 'system', 'request_id' => $this->requestId, 'source' => 'output_buffer', - 'data' => substr($buffer, 0, 5000), // Truncate to prevent huge logs + // The captured buffer is opaque — we can't field-redact a + // string. If the originating context is sensitive, drop + // the buffer contents entirely and keep only the + // metadata so the row remains useful for audit. + 'data' => $shouldDrop ? null : substr($buffer, 0, 5000), 'context' => json_encode([ 'buffer_length' => strlen($buffer), 'truncated' => strlen($buffer) > 5000, + 'sensitive_dropped' => $shouldDrop, ]), ]; diff --git a/src/Mcp/Attribute/RequiresScope.php b/src/Mcp/Attribute/RequiresScope.php new file mode 100644 index 00000000..991fc731 --- /dev/null +++ b/src/Mcp/Attribute/RequiresScope.php @@ -0,0 +1,31 @@ +account). + * 2. Run the mcp/sdk Server with attribute-based discovery on the Tools dir. + * 3. Emit the resulting PSR-7 response back to the client. + * + * Bypasses Api::validateRequest() entirely. The standard MVC pipeline assumes + * Kyte's URL-shaped routing (POST /{model} + data) and HMAC-style response + * envelopes, neither of which apply to JSON-RPC over MCP. Calling + * Api::route() detects `/mcp` early and delegates here before any of that runs. + * + * Session storage uses the SDK's bundled FileSessionStore, scoped per-install + * under sys_get_temp_dir(). MCP sessions are short-lived and per-Claude- + * conversation, so file storage is sufficient at per-tenant scale. A MySQL- + * backed store can replace this if/when SaaS-scale deployment forces the + * issue (see design doc section 11). + * + * The handle()/process() split is for testability: process() is pure + * request-in / response-out and can be exercised from PHPUnit, while handle() + * binds it to PHP superglobals + SapiEmitter for real request handling. + */ +final class Endpoint +{ + /** Production entry point. Reads from globals, emits to SAPI. */ + public static function handle(Api $api): void + { + $request = self::requestFromGlobals(); + $response = self::process($api, $request); + (new SapiEmitter())->emit($response); + } + + /** + * Pure request-in / response-out. Authenticates, dispatches via the SDK, + * returns the resulting PSR-7 response. Auth failures and pre-dispatch + * errors come back as JSON-RPC-shaped error responses with the + * appropriate HTTP status — never as exceptions thrown to the caller. + */ + public static function process(Api $api, ServerRequestInterface $request): ResponseInterface + { + $psr17 = new Psr17Factory(); + + // Origin check runs before auth — DNS rebinding doesn't care about + // tokens, and rejecting fast saves a DB hit on the bearer lookup. + $originError = self::checkOrigin($request); + if ($originError !== null) { + return self::jsonRpcError($psr17, 403, -32011, $originError); + } + + try { + self::authenticate($api, $request); + } catch (SessionException $e) { + return self::jsonRpcError($psr17, 401, -32001, $e->getMessage()); + } catch (\Throwable $e) { + return self::jsonRpcError($psr17, 500, -32603, 'Internal MCP error: ' . $e->getMessage()); + } + + $transport = new StreamableHttpTransport( + $request, + responseFactory: $psr17, + streamFactory: $psr17, + ); + + $container = new McpContainer(); + $container->set(Api::class, $api); + + $sessionDir = self::sessionDirectory(); + + // Build registry + inner CallToolHandler ourselves so we can wrap the + // dispatch with ScopedCallToolHandler. The Builder otherwise creates + // these privately inside build(); registering our own registry via + // setRegistry() lets the SDK's loaders populate the same instance our + // wrapper later reads from. addRequestHandler() prepends to the + // handler list, so our wrapper wins the first-supports-wins dispatch + // in Server\Protocol over the SDK's default CallToolHandler. + $registry = new McpRegistry(); + $referenceHandler = new ReferenceHandler($container); + $innerCallTool = new CallToolHandler($registry, $referenceHandler); + $scopedCallTool = new ScopedCallToolHandler($innerCallTool, new ScopeRegistry($registry), $api); + + $server = Server::builder() + ->setServerInfo( + 'Kyte MCP', + \Kyte\Core\Version::get(), + 'Kyte low-code framework MCP endpoint' + ) + ->setInstructions( + 'Tools operate on the account associated with the bearer token. ' . + 'Use list_applications to discover apps, then traditional Kyte ' . + 'workflows for further work. Additional tools land in subsequent ' . + 'Phase 2 commits.' + ) + ->setContainer($container) + ->setRegistry($registry) + ->addRequestHandler($scopedCallTool) + ->setSession(new FileSessionStore($sessionDir), new SaveSafeSessionFactory()) + ->setDiscovery(__DIR__ . '/Tools') + ->build(); + + return $server->run($transport); + } + + private static function requestFromGlobals(): ServerRequestInterface + { + $psr17 = new Psr17Factory(); + $creator = new ServerRequestCreator($psr17, $psr17, $psr17, $psr17); + return $creator->fromGlobals(); + } + + /** + * Run the auth dispatcher to populate $api->account from the bearer token. + * Bypasses validateRequest() since we don't want its HMAC-flavored response + * envelope side-effects (kyte_pub, kyte_iden, etc.) leaking into MCP. + * + * Reads the Authorization header from the PSR-7 request rather than from + * globals so this path is testable. McpTokenStrategy still consults + * $_SERVER itself; the test harness sets both consistently. + */ + private static function authenticate(Api $api, ServerRequestInterface $request): void + { + $authHeader = $request->getHeaderLine('Authorization'); + if ($authHeader === '') { + throw new SessionException('[ERROR] /mcp requires an Authorization header.'); + } + + $strategy = AuthDispatcher::buildDefault()->select(); + if (!$strategy instanceof McpTokenStrategy) { + throw new SessionException('[ERROR] /mcp requires an MCP bearer token (kmcp_live_...).'); + } + + $strategy->preAuth($api); + $strategy->verify($api); // no-op for bearer; kept for symmetry + + $api->mcpToken = $strategy->token; + $api->mcpScopes = $strategy->scopes; + } + + /** + * MCP spec § Security requires servers to validate the Origin header to + * prevent DNS rebinding attacks. The attack vector is browser-only: + * malicious JavaScript on attacker.com tricks the victim's browser into + * POSTing to a Kyte instance, exploiting the bearer-token auth that the + * browser may have cached. CLI clients (Claude Code) are not affected + * because there is no shared-cookie / shared-credential context for an + * attacker to exploit. + * + * Policy: + * - No Origin header → allow. CLI clients (Claude Code, curl, gust) + * don't send Origin. Forcing one would break every non-browser + * integration without a security benefit. + * - Origin present + matches allowlist → allow. + * - Origin present + no match → 403 + JSON-RPC -32011. + * + * Allowlist source: the per-install `MCP_ALLOWED_ORIGINS` PHP constant + * (CSV of full origins, e.g. `"https://claude.ai,https://app.example.com"`). + * Empty / undefined means "no browser origins are allowed" — Claude.ai + * custom-connector users must opt in by setting the constant in their + * config.php. Restrictive default is intentional: a permissive + * allowlist would broaden the attack surface for browser-borne + * requests without an explicit opt-in signal from the operator. + * + * Returns the rejection reason string on failure, or null on pass. + */ + private static function checkOrigin(ServerRequestInterface $request): ?string + { + $origin = trim($request->getHeaderLine('Origin')); + if ($origin === '') { + return null; + } + + $allowed = defined('MCP_ALLOWED_ORIGINS') ? (string)MCP_ALLOWED_ORIGINS : ''; + $list = array_values(array_filter(array_map('trim', explode(',', $allowed)), fn ($v) => $v !== '')); + + if (in_array($origin, $list, true)) { + return null; + } + + return "Origin '{$origin}' is not in the MCP_ALLOWED_ORIGINS allowlist."; + } + + private static function sessionDirectory(): string + { + $dir = sys_get_temp_dir() . '/kyte-mcp-sessions'; + if (!is_dir($dir)) { + @mkdir($dir, 0700, true); + } + return $dir; + } + + private static function jsonRpcError(Psr17Factory $psr17, int $httpStatus, int $rpcCode, string $message): ResponseInterface + { + $body = json_encode([ + 'jsonrpc' => '2.0', + 'id' => null, + 'error' => ['code' => $rpcCode, 'message' => $message], + ]); + return $psr17->createResponse($httpStatus) + ->withHeader('Content-Type', 'application/json') + ->withBody($psr17->createStream($body)); + } +} diff --git a/src/Mcp/ScopeRegistry.php b/src/Mcp/ScopeRegistry.php new file mode 100644 index 00000000..8b40fc66 --- /dev/null +++ b/src/Mcp/ScopeRegistry.php @@ -0,0 +1,76 @@ + tool name → required scope, null = deny */ + private array $cache = []; + + public function __construct(private readonly RegistryInterface $sdkRegistry) + { + } + + public function requiredScopeFor(string $toolName): ?string + { + if (\array_key_exists($toolName, $this->cache)) { + return $this->cache[$toolName]; + } + + try { + $reference = $this->sdkRegistry->getTool($toolName); + } catch (ToolNotFoundException) { + // Unknown tool. Returning null routes through the dispatcher's + // deny path; the SDK's CallToolHandler would have surfaced its + // own ToolNotFoundException anyway, so the user sees a scope + // error first instead of a not-found error. That's fine — a + // missing tool that requires no scope is still a missing tool. + return $this->cache[$toolName] = null; + } + + $handler = $reference->handler; + if (!\is_array($handler) || \count($handler) !== 2) { + // Discovered Kyte tools always come back as [class, method] (see + // Discoverer::processMethod in vendor/mcp/sdk). A closure or + // function-typed handler would mean someone hand-registered a + // tool through Builder::addTool — we don't do that today, and if + // we ever do, those tools must also expose a scope somehow. For + // now, refuse to dispatch. + return $this->cache[$toolName] = null; + } + + [$className, $methodName] = $handler; + try { + $reflection = new \ReflectionMethod($className, $methodName); + } catch (\ReflectionException) { + return $this->cache[$toolName] = null; + } + + $attrs = $reflection->getAttributes(RequiresScope::class); + if (empty($attrs)) { + return $this->cache[$toolName] = null; + } + + return $this->cache[$toolName] = $attrs[0]->newInstance()->scope; + } +} diff --git a/src/Mcp/ScopedCallToolHandler.php b/src/Mcp/ScopedCallToolHandler.php new file mode 100644 index 00000000..f7fc0302 --- /dev/null +++ b/src/Mcp/ScopedCallToolHandler.php @@ -0,0 +1,116 @@ +mcpScopes, populated by McpTokenStrategy + * in Endpoint::authenticate. We do not re-validate the token here — auth + * has already happened. This class is strictly authorization. + * + * JSON-RPC error code -32010 is in the application-error range + * (-32000..-32099 per the JSON-RPC 2.0 spec). Endpoint already uses + * -32001 for auth failures; the SDK reserves -32002 internally for + * RESOURCE_NOT_FOUND. -32010 keeps scope failures distinguishable in + * client logs without colliding with anything the SDK emits. + * + * @implements RequestHandlerInterface<\Mcp\Schema\Result\CallToolResult> + */ +final class ScopedCallToolHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly CallToolHandler $inner, + private readonly ScopeRegistry $scopes, + private readonly Api $api, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof CallToolRequest; + } + + public function handle(Request $request, SessionInterface $session): Response|Error + { + \assert($request instanceof CallToolRequest); + + $toolName = $request->name; + $required = $this->scopes->requiredScopeFor($toolName); + $present = $this->api->mcpScopes ?? []; + + if ($required === null) { + $this->logViolation($toolName, '', $present); + return new Error( + $request->getId(), + -32010, + "Tool '{$toolName}' has no scope declaration; refusing to dispatch." + ); + } + + if (!\in_array($required, $present, true)) { + $this->logViolation($toolName, $required, $present); + return new Error( + $request->getId(), + -32010, + "Tool '{$toolName}' requires scope '{$required}'; token scopes: [" + . implode(',', $present) . "]." + ); + } + + return $this->inner->handle($request, $session); + } + + /** + * Best-effort audit write. ActivityLogger swallows its own failures and + * we wrap again here because a logging error must never escape into + * the JSON-RPC response — the scope decision still stands. + * + * @param string[] $present + */ + private function logViolation(string $toolName, string $required, array $present): void + { + // The token's primary key goes into the dedicated record_id column + // rather than into request_data. ActivityLogger::redactSensitive + // does substring matching against a "token" needle, so any payload + // key containing "token" (including a harmless surrogate like + // "token_id") would be replaced with [REDACTED] and the audit row + // would lose the link back to the offending credential. + $tokenId = isset($this->api->mcpToken->id) ? (int)$this->api->mcpToken->id : null; + + try { + ActivityLogger::getInstance()->log( + 'MCP_SCOPE_VIOLATION', + 'KyteMCPToken', + 'tool', + $toolName, + [ + 'required_scope' => $required, + 'present_scopes' => $present, + ], + 403, + 'denied', + "Scope '{$required}' missing from token", + $tokenId + ); + } catch (\Throwable $e) { + error_log('ScopedCallToolHandler: failed to log scope violation - ' . $e->getMessage()); + } + } +} diff --git a/src/Mcp/Session/SaveSafeSession.php b/src/Mcp/Session/SaveSafeSession.php new file mode 100644 index 00000000..1e960f05 --- /dev/null +++ b/src/Mcp/Session/SaveSafeSession.php @@ -0,0 +1,62 @@ +data` directly: + * + * public function save(): bool + * { + * return $this->store->write($this->id, json_encode($this->data, ...)); + * } + * + * `$data` is a typed array property with no default value, so when no + * `set()` / `clear()` / `readData()` call has hydrated it yet, accessing + * it raises a fatal "must not be accessed before initialization" error. + * That happens in practice on every tool/call request that doesn't write + * to the session — which is most of them. The fatal kills the FPM worker + * mid-request; the response gets dropped and the client sees an HTTP 202 + * with an empty body. + * + * The fix is one line: route through `readData()` (which lazy-inits to + * the persisted store value, or to `[]` on miss). All other methods on + * the parent class already do this; `save()` is the lone outlier. + * + * Filed upstream — once it lands and we bump `mcp/sdk`, this whole + * subclass + `SaveSafeSessionFactory` can go away. Until then we wire + * the subclass via `Builder::setSession(..., new SaveSafeSessionFactory())` + * in `Endpoint::process` so customers get the fix automatically without + * any vendor patching. + */ +final class SaveSafeSession extends Session +{ + public function save(): bool + { + return $this->getStore()->write( + $this->getId(), + json_encode($this->all(), \JSON_THROW_ON_ERROR) + ); + } +} diff --git a/src/Mcp/Session/SaveSafeSessionFactory.php b/src/Mcp/Session/SaveSafeSessionFactory.php new file mode 100644 index 00000000..599304f5 --- /dev/null +++ b/src/Mcp/Session/SaveSafeSessionFactory.php @@ -0,0 +1,29 @@ +account, which McpTokenStrategy::preAuth + * populates from the token's kyte_account FK before the tool runs. + */ +final class AccountTools +{ + public function __construct(private readonly Api $api) + { + } + + /** + * List Kyte applications owned by the authenticated account. + * + * Returns one entry per Application row scoped to the token's account. + * Identifiers are stable; clients can pass the `identifier` value as the + * X-Kyte-AppId for traditional API calls if cross-protocol bridging is + * needed. + * + * Wrapped in {applications: [...]} rather than returning a bare list: + * MCP spec requires structured tool output to be a JSON object (record), + * not a list. The mcp/sdk's extractStructuredContent passes any returned + * array through verbatim, so a bare list violates client-side schema + * validation. Same wrapping pattern across every list_* tool. + * + * @return array{applications: array} + */ + #[McpTool(name: 'list_applications', description: 'List Kyte applications for the authenticated account.')] + #[RequiresScope('read')] + public function listApplications(): array + { + $accountId = isset($this->api->account->id) ? (int)$this->api->account->id : 0; + if ($accountId === 0) { + return ['applications' => []]; + } + + $model = new \Kyte\Core\Model(\Application); + $model->retrieve('kyte_account', $accountId); + + $out = []; + foreach ($model->objects as $app) { + $out[] = [ + 'id' => (int)$app->id, + 'name' => (string)($app->name ?? ''), + 'identifier' => (string)($app->identifier ?? ''), + ]; + } + return ['applications' => $out]; + } +} diff --git a/src/Mcp/Tools/ControllerTools.php b/src/Mcp/Tools/ControllerTools.php new file mode 100644 index 00000000..cf4db12e --- /dev/null +++ b/src/Mcp/Tools/ControllerTools.php @@ -0,0 +1,255 @@ +account — never trust the caller's id alone. Without + * that re-check, a token holder could enumerate any account's + * controllers / functions by trying integer ids in sequence. + * + * Code-bearing fields (Controller::code, Function::code, + * KyteFunctionVersionContent::code) are intentionally returned in full. + * The whole point of MCP read access is letting Claude reason over the + * source — withholding code here would defeat the design. Token scope + * gates this; Shipyard issues 'read' tokens deliberately. + */ +final class ControllerTools +{ + public function __construct(private readonly Api $api) + { + } + + /** + * List controllers attached to a Kyte application. + * + * Returns metadata only (no code) — keep tools/list responses small; + * the caller fetches code via read_controller when it picks one to + * inspect. Virtual controllers (no dataModel) appear too. + * + * @param int $application_id Application id from list_applications. + * @return array{controllers: array} + */ + #[McpTool(name: 'list_controllers', description: 'List controllers in a Kyte application. Returns metadata only — call read_controller for code.')] + #[RequiresScope('read')] + public function listControllers(int $application_id): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->applicationBelongsToAccount($application_id, $accountId)) { + return ['controllers' => []]; + } + + $model = new \Kyte\Core\Model(\Controller); + $model->retrieve('application', $application_id, false, [ + ['field' => 'kyte_account', 'value' => $accountId], + ]); + + $out = []; + foreach ($model->objects as $controller) { + $out[] = [ + 'id' => (int)$controller->id, + 'name' => (string)($controller->name ?? ''), + 'description' => $controller->description !== null ? (string)$controller->description : null, + 'dataModel' => $controller->dataModel !== null ? (int)$controller->dataModel : null, + 'kyte_locked' => (int)$controller->kyte_locked === 1, + // Surface the sensitive flag so callers (including AI clients) + // know up front which controllers will have source withheld + // by read_controller. Names and metadata are not themselves + // sensitive — only code is gated. + 'sensitive' => (int)($controller->sensitive ?? 0) === 1, + ]; + } + return ['controllers' => $out]; + } + + /** + * Read a controller's full record, including its PHP source code. + * + * Account scoping is re-verified — supplying a controller_id from + * another account returns null rather than the foreign record. + * + * @param int $controller_id Controller id from list_controllers. + * @return array{id:int, name:string, description:?string, dataModel:?int, application:?int, code:string, kyte_locked:bool}|null + */ + #[McpTool(name: 'read_controller', description: 'Read a controller including its PHP source code. Code is withheld when the controller is flagged sensitive — the metadata still returns and the `sensitive` field is true.')] + #[RequiresScope('read')] + public function readController(int $controller_id): ?array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0) { + return null; + } + + $controller = new \Kyte\Core\ModelObject(\Controller); + if (!$controller->retrieve('id', $controller_id) || (int)$controller->kyte_account !== $accountId) { + return null; + } + + $isSensitive = (int)($controller->sensitive ?? 0) === 1; + + return [ + 'id' => (int)$controller->id, + 'name' => (string)($controller->name ?? ''), + 'description' => $controller->description !== null ? (string)$controller->description : null, + 'dataModel' => $controller->dataModel !== null ? (int)$controller->dataModel : null, + 'application' => $controller->application !== null ? (int)$controller->application : null, + // Source withheld when sensitive — same logic that drops body + // from activity/error logs. AI clients should treat a null code + // with sensitive:true as "exists, source intentionally gated." + 'code' => $isSensitive ? null : Bz2Codec::decompressIfBz2($controller->code), + 'kyte_locked' => (int)$controller->kyte_locked === 1, + 'sensitive' => $isSensitive, + ]; + } + + /** + * List functions (hooks + custom) attached to a controller. + * + * Function `type` distinguishes hooks ('hook_init', 'hook_preprocess' + * etc.), method overrides ('new', 'update', 'get', 'delete'), and + * 'custom' helpers. The skill bundle docs lay out which type slots + * exist and what they do; this tool just surfaces what's there. + * + * @param int $controller_id Controller id from list_controllers. + * @return array{functions: array} + */ + #[McpTool(name: 'list_functions', description: 'List functions (hooks + custom) attached to a Kyte controller.')] + #[RequiresScope('read')] + public function listFunctions(int $controller_id): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->controllerBelongsToAccount($controller_id, $accountId)) { + return ['functions' => []]; + } + + $model = new \Kyte\Core\Model(constant('Function')); + $model->retrieve('controller', $controller_id, false, [ + ['field' => 'kyte_account', 'value' => $accountId], + ]); + + $out = []; + foreach ($model->objects as $fn) { + $out[] = [ + 'id' => (int)$fn->id, + 'name' => (string)($fn->name ?? ''), + 'type' => (string)($fn->type ?? ''), + 'description' => $fn->description !== null ? (string)$fn->description : null, + 'kyte_locked' => (int)$fn->kyte_locked === 1, + ]; + } + return ['functions' => $out]; + } + + /** + * Read a function's source code, optionally at a specific historical version. + * + * Without `version_number`, returns the live Function row's `code`. + * With `version_number`, looks up the matching KyteFunctionVersion + * snapshot and joins to KyteFunctionVersionContent for the source as + * it was at that version. Returns null if either the function or the + * requested version doesn't exist (or belongs to another account). + * + * Versioning was added per the design doc 3.3 draft model — this + * tool gives Claude a way to inspect prior states when reasoning + * about a change, without requiring Shipyard. + * + * @param int $function_id Function id from list_functions. + * @param int|null $version_number Optional KyteFunctionVersion.version_number. + * @return array{id:int, name:string, type:string, description:?string, code:string, version:?int, version_type:?string}|null + */ + #[McpTool(name: 'read_function', description: 'Read a function source. Pass version_number to retrieve a specific historical snapshot, or omit for the live source. Code is withheld when the parent controller is flagged sensitive.')] + #[RequiresScope('read')] + public function readFunction(int $function_id, ?int $version_number = null): ?array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0) { + return null; + } + + $fn = new \Kyte\Core\ModelObject(constant('Function')); + if (!$fn->retrieve('id', $function_id) || (int)$fn->kyte_account !== $accountId) { + return null; + } + + // If the parent controller is sensitive the function source is + // gated regardless of which version is requested. Historical + // snapshots that pre-date the flag would still be off-limits; + // the flag applies to current policy, not the row state at + // snapshot time. + $parentSensitive = false; + if ($fn->controller !== null) { + $parent = new \Kyte\Core\ModelObject(\Controller); + if ($parent->retrieve('id', (int)$fn->controller) && (int)$parent->kyte_account === $accountId) { + $parentSensitive = (int)($parent->sensitive ?? 0) === 1; + } + } + + $base = [ + 'id' => (int)$fn->id, + 'name' => (string)($fn->name ?? ''), + 'type' => (string)($fn->type ?? ''), + 'description' => $fn->description !== null ? (string)$fn->description : null, + 'version' => null, + 'version_type' => null, + 'sensitive' => $parentSensitive, + ]; + + if ($version_number === null) { + return array_merge($base, [ + 'code' => $parentSensitive ? null : Bz2Codec::decompressIfBz2($fn->code), + ]); + } + + $version = new \Kyte\Core\ModelObject(\KyteFunctionVersion); + $found = $version->retrieve('function', $function_id, [ + ['field' => 'version_number', 'value' => $version_number], + ['field' => 'kyte_account', 'value' => $accountId], + ]); + if (!$found) { + return null; + } + + $content = new \Kyte\Core\ModelObject(\KyteFunctionVersionContent); + if (!$content->retrieve('content_hash', (string)$version->content_hash)) { + return null; + } + + // array_merge — not the `+` union operator — so that the version + // overrides below replace the nulls in $base. PHP's `+` keeps the + // left-hand value on key collision, which would silently drop the + // version metadata. + return array_merge($base, [ + 'code' => $parentSensitive ? null : Bz2Codec::decompressIfBz2($content->code), + 'version' => (int)$version->version_number, + 'version_type' => (string)($version->version_type ?? ''), + ]); + } + + private function accountIdOrZero(): int + { + return isset($this->api->account->id) ? (int)$this->api->account->id : 0; + } + + /** + * Defensive precondition for application-scoped lookups. Cheap (one + * indexed read by id) and prevents enumeration of foreign apps. + */ + private function applicationBelongsToAccount(int $applicationId, int $accountId): bool + { + $app = new \Kyte\Core\ModelObject(\Application); + return $app->retrieve('id', $applicationId) && (int)$app->kyte_account === $accountId; + } + + private function controllerBelongsToAccount(int $controllerId, int $accountId): bool + { + $controller = new \Kyte\Core\ModelObject(\Controller); + return $controller->retrieve('id', $controllerId) && (int)$controller->kyte_account === $accountId; + } +} diff --git a/src/Mcp/Tools/ModelTools.php b/src/Mcp/Tools/ModelTools.php new file mode 100644 index 00000000..0fbe589e --- /dev/null +++ b/src/Mcp/Tools/ModelTools.php @@ -0,0 +1,159 @@ +} + */ + #[McpTool(name: 'list_models', description: 'List data models in a Kyte application. Returns metadata only — call read_model for the full schema.')] + #[RequiresScope('read')] + public function listModels(int $application_id): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->applicationBelongsToAccount($application_id, $accountId)) { + return ['models' => []]; + } + + $model = new \Kyte\Core\Model(\DataModel); + $model->retrieve('application', $application_id, false, [ + ['field' => 'kyte_account', 'value' => $accountId], + ]); + + $out = []; + foreach ($model->objects as $dm) { + $out[] = [ + 'id' => (int)$dm->id, + 'name' => (string)($dm->name ?? ''), + 'kyte_locked' => (int)$dm->kyte_locked === 1, + // Surface the sensitive flag so callers know up front + // which models will have definition gated by read_model. + 'sensitive' => (int)($dm->sensitive ?? 0) === 1, + ]; + } + return ['models' => $out]; + } + + /** + * Read a data model's full record, including its decoded schema. + * + * `model_definition` is stored as a JSON string but returned here + * as a decoded array so the caller doesn't have to parse it again. + * If the stored JSON is missing or invalid, `definition` comes back + * as null with the raw value preserved in `raw_definition` for + * debugging — better than throwing and breaking the dispatch. + * + * @param int $model_id DataModel id from list_models. + * @return array{id:int, name:string, application:?int, kyte_locked:bool, definition:?array, raw_definition:?string}|null + */ + #[McpTool(name: 'read_model', description: 'Read a data model including its schema definition. When the model is flagged sensitive the definition is withheld; when individual fields are flagged sensitive they are stripped from the returned struct.')] + #[RequiresScope('read')] + public function readModel(int $model_id): ?array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0) { + return null; + } + + $dm = new \Kyte\Core\ModelObject(\DataModel); + if (!$dm->retrieve('id', $model_id) || (int)$dm->kyte_account !== $accountId) { + return null; + } + + $modelName = (string)($dm->name ?? ''); + $isSensitive = (int)($dm->sensitive ?? 0) === 1; + + // Model flagged sensitive → definition entirely withheld. Metadata + // (id, name, application) still returns so the caller can discover + // that the model exists; the definition itself is gated. + if ($isSensitive) { + return [ + 'id' => (int)$dm->id, + 'name' => $modelName, + 'application' => $dm->application !== null ? (int)$dm->application : null, + 'kyte_locked' => (int)$dm->kyte_locked === 1, + 'definition' => null, + 'raw_definition' => null, + 'sensitive' => true, + 'sensitive_fields' => [], + ]; + } + + $raw = $dm->model_definition !== null ? (string)$dm->model_definition : null; + $decoded = null; + if ($raw !== null && $raw !== '') { + $tryDecode = json_decode($raw, true); + if (is_array($tryDecode)) { + $decoded = $tryDecode; + } + } + + // Field-level: strip any field flagged sensitive from definition.struct. + // The caller still sees that those fields exist via the returned + // sensitive_fields list, but their type / size / FK metadata is + // withheld so it can't be reasoned over. + $sensitiveFields = SensitivityPolicy::getInstance()->getSensitiveFields($modelName, $accountId); + if ($decoded !== null && !empty($sensitiveFields) && isset($decoded['struct']) && is_array($decoded['struct'])) { + $sensitiveLower = array_map('strtolower', $sensitiveFields); + foreach (array_keys($decoded['struct']) as $fieldName) { + if (is_string($fieldName) && in_array(strtolower($fieldName), $sensitiveLower, true)) { + unset($decoded['struct'][$fieldName]); + } + } + } + + return [ + 'id' => (int)$dm->id, + 'name' => $modelName, + 'application' => $dm->application !== null ? (int)$dm->application : null, + 'kyte_locked' => (int)$dm->kyte_locked === 1, + 'definition' => $decoded, + 'raw_definition' => $decoded === null ? $raw : null, + 'sensitive' => false, + 'sensitive_fields' => $sensitiveFields, + ]; + } + + private function accountIdOrZero(): int + { + return isset($this->api->account->id) ? (int)$this->api->account->id : 0; + } + + private function applicationBelongsToAccount(int $applicationId, int $accountId): bool + { + $app = new \Kyte\Core\ModelObject(\Application); + return $app->retrieve('id', $applicationId) && (int)$app->kyte_account === $accountId; + } +} diff --git a/src/Mcp/Tools/PageTools.php b/src/Mcp/Tools/PageTools.php new file mode 100644 index 00000000..d093fed9 --- /dev/null +++ b/src/Mcp/Tools/PageTools.php @@ -0,0 +1,246 @@ +} + */ + #[McpTool(name: 'list_sites', description: 'List sites in a Kyte application.')] + #[RequiresScope('read')] + public function listSites(int $application_id): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->applicationBelongsToAccount($application_id, $accountId)) { + return ['sites' => []]; + } + + $model = new \Kyte\Core\Model(\KyteSite); + $model->retrieve('application', $application_id, false, [ + ['field' => 'kyte_account', 'value' => $accountId], + ]); + + $out = []; + foreach ($model->objects as $site) { + $out[] = [ + 'id' => (int)$site->id, + 'name' => (string)($site->name ?? ''), + 'status' => (string)($site->status ?? ''), + 'region' => $site->region !== null ? (string)$site->region : null, + 'default_lang' => $site->default_lang !== null ? (string)$site->default_lang : null, + 'description' => $site->description !== null ? (string)$site->description : null, + ]; + } + return ['sites' => $out]; + } + + /** + * List pages on a site. + * + * @param int $site_id Site id from list_sites. + * @return array{pages: array} + */ + #[McpTool(name: 'list_pages', description: 'List pages on a Kyte site. Returns metadata only — call read_page for HTML/CSS/JS content.')] + #[RequiresScope('read')] + public function listPages(int $site_id): array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0 || !$this->siteBelongsToAccount($site_id, $accountId)) { + return ['pages' => []]; + } + + $model = new \Kyte\Core\Model(\KytePage); + $model->retrieve('site', $site_id, false, [ + ['field' => 'kyte_account', 'value' => $accountId], + ]); + + $out = []; + foreach ($model->objects as $page) { + $out[] = [ + 'id' => (int)$page->id, + 'title' => (string)($page->title ?? ''), + 'page_type' => (string)($page->page_type ?? ''), + 'state' => (int)$page->state, + 'lang' => $page->lang !== null ? (string)$page->lang : null, + 'sitemap_include' => (int)$page->sitemap_include === 1, + // Surface the sensitive flag so callers know up front + // which pages will have html/css/js withheld by read_page. + 'sensitive' => (int)($page->sensitive ?? 0) === 1, + ]; + } + return ['pages' => $out]; + } + + /** + * Read a page's full content (html, stylesheet, javascript), optionally at + * a specific version. + * + * Without `version_number`, returns the version flagged is_current=1. + * With `version_number`, returns that historical snapshot. Returns + * null when the page (or requested version) doesn't exist or + * belongs to another account. Returns the page with empty content + * when the page exists but has no current version yet. + * + * @param int $page_id KytePage id from list_pages. + * @param int|null $version_number Optional KytePageVersion.version_number. + * @return array{id:int, title:string, description:?string, page_type:string, state:int, site:?int, html:string, stylesheet:string, javascript:string, version:?int, version_type:?string}|null + */ + #[McpTool(name: 'read_page', description: 'Read a page including its HTML, stylesheet, and JavaScript. Pass version_number to retrieve a specific historical snapshot, or omit for the current published content.')] + #[RequiresScope('read')] + public function readPage(int $page_id, ?int $version_number = null): ?array + { + $accountId = $this->accountIdOrZero(); + if ($accountId === 0) { + return null; + } + + $page = new \Kyte\Core\ModelObject(\KytePage); + if (!$page->retrieve('id', $page_id) || (int)$page->kyte_account !== $accountId) { + return null; + } + + $isSensitive = (int)($page->sensitive ?? 0) === 1; + + $base = [ + 'id' => (int)$page->id, + 'title' => (string)($page->title ?? ''), + 'description' => $page->description !== null ? (string)$page->description : null, + 'page_type' => (string)($page->page_type ?? ''), + 'state' => (int)$page->state, + 'site' => $page->site !== null ? (int)$page->site : null, + 'version' => null, + 'version_type' => null, + 'sensitive' => $isSensitive, + ]; + + // Page flagged sensitive → withhold content regardless of which + // version was requested. Same policy semantics as read_controller: + // metadata returns, source/content does not. + if ($isSensitive) { + return array_merge($base, [ + 'html' => null, + 'stylesheet' => null, + 'javascript' => null, + ]); + } + + $version = new \Kyte\Core\ModelObject(\KytePageVersion); + $found = false; + if ($version_number === null) { + $found = $version->retrieve('page', $page_id, [ + ['field' => 'is_current', 'value' => 1], + ['field' => 'kyte_account', 'value' => $accountId], + ]); + } else { + $found = $version->retrieve('page', $page_id, [ + ['field' => 'version_number', 'value' => $version_number], + ['field' => 'kyte_account', 'value' => $accountId], + ]); + if (!$found) { + // Versioned read against a missing version is an explicit + // miss — distinct from "page exists but never saved." Tell + // the caller "no such version" via null rather than a + // potentially-misleading empty content response. + return null; + } + } + + if (!$found) { + // No current version yet (default-version path only). Return + // the page metadata with empty content so the caller can still + // tell the page exists. + return array_merge($base, [ + 'html' => '', + 'stylesheet' => '', + 'javascript' => '', + ]); + } + + $content = new \Kyte\Core\ModelObject(\KytePageVersionContent); + if (!$content->retrieve('content_hash', (string)$version->content_hash)) { + // Orphaned version (content row missing). Same shape as no-content, + // but return the version metadata so the caller knows what they + // pointed at — helps diagnose data corruption rather than + // silently masking it as "empty page." + return array_merge($base, [ + 'html' => '', + 'stylesheet' => '', + 'javascript' => '', + 'version' => (int)$version->version_number, + 'version_type' => (string)($version->version_type ?? ''), + ]); + } + + return array_merge($base, [ + 'html' => Bz2Codec::decompressIfBz2($content->html), + 'stylesheet' => Bz2Codec::decompressIfBz2($content->stylesheet), + 'javascript' => Bz2Codec::decompressIfBz2($content->javascript), + 'version' => (int)$version->version_number, + 'version_type' => (string)($version->version_type ?? ''), + ]); + } + + private function accountIdOrZero(): int + { + return isset($this->api->account->id) ? (int)$this->api->account->id : 0; + } + + private function applicationBelongsToAccount(int $applicationId, int $accountId): bool + { + $app = new \Kyte\Core\ModelObject(\Application); + return $app->retrieve('id', $applicationId) && (int)$app->kyte_account === $accountId; + } + + /** + * Site-belongs-to-account check. Used by list_pages — the site_id + * argument is caller-supplied, so re-verify the chain before walking + * down to pages. + */ + private function siteBelongsToAccount(int $siteId, int $accountId): bool + { + $site = new \Kyte\Core\ModelObject(\KyteSite); + return $site->retrieve('id', $siteId) && (int)$site->kyte_account === $accountId; + } +} diff --git a/src/Mcp/Util/Bz2Codec.php b/src/Mcp/Util/Bz2Codec.php new file mode 100644 index 00000000..845c574d --- /dev/null +++ b/src/Mcp/Util/Bz2Codec.php @@ -0,0 +1,34 @@ +prepareIssuance($r); + break; + case 'update': + // Don't allow callers to PUT a new token_hash / prefix / + // scopes-bypass via update. Revocation goes through DELETE + // only for now; if PUT-based revoke is added later it must + // flow through this controller's update hook. + unset($r['token_hash'], $r['token_prefix'], $r['kyte_account']); + break; + default: + break; + } + } + + public function hook_response_data($method, $o, &$r = null, &$d = null) { + switch ($method) { + case 'new': + $this->finalizeIssuance($o, $r); + break; + case 'delete': + $this->logRevoke($o); + break; + default: + break; + } + } + + /** + * Generate raw token + populate request data with hash/prefix. + * + * Force-overrides kyte_account so a session for account A cannot + * mint a token scoped to account B by sending `kyte_account: B` in + * the POST body — see class docblock for the framework default that + * makes this a real attack surface. + * + * @param array $r + */ + private function prepareIssuance(array &$r): void + { + // Mandatory account binding from auth context. Discard whatever + // the request sent for this field. + if (!isset($this->api->account->id)) { + throw new \Exception('Cannot issue MCP token without an authenticated account context.'); + } + $r['kyte_account'] = (int)$this->api->account->id; + + // Discard server-controlled fields if present in the request. + unset($r['token_hash'], $r['token_prefix'], $r['last_used_at'], $r['last_used_ip'], $r['revoked_at']); + + // Validate scopes (CSV: any combination of read/draft/commit, no others). + if (!isset($r['scopes']) || !is_string($r['scopes']) || trim($r['scopes']) === '') { + throw new \Exception('scopes is required (CSV of: ' . implode(', ', self::VALID_SCOPES) . ')'); + } + $requested = array_values(array_filter(array_map('trim', explode(',', $r['scopes'])))); + $invalid = array_diff($requested, self::VALID_SCOPES); + if (!empty($invalid)) { + throw new \Exception('Invalid scope(s): ' . implode(', ', $invalid) . '. Allowed: ' . implode(', ', self::VALID_SCOPES)); + } + $r['scopes'] = implode(',', $requested); + + // TTL default — explicit "never expires" (0) is allowed but never + // automatic. Design doc R2 says short TTLs are a primary mitigation; + // we make the safe choice the default. + // + // Date fields go through ModelController::sift() which calls + // strtotime() on the value before persisting. Passing a Unix int + // makes strtotime() return false, which lands in the DB as 0 — + // silently breaking the expiry. We format as ISO 8601 so sift's + // strtotime parses it back to the integer we wanted. + if (!isset($r['expires_at']) || $r['expires_at'] === '' || $r['expires_at'] === null) { + $r['expires_at'] = date('Y-m-d H:i:s', time() + self::DEFAULT_TTL_SECONDS); + } elseif (is_int($r['expires_at']) || ctype_digit((string)$r['expires_at'])) { + // Caller sent a Unix timestamp; convert so sift doesn't blank it. + $r['expires_at'] = date('Y-m-d H:i:s', (int)$r['expires_at']); + } + + // Generate the raw token. 32 base62 chars after the prefix gives + // ~190 bits of entropy — plenty against brute force. + $raw = self::generateRawToken(); + $this->newRawToken = $raw; + $r['token_hash'] = hash('sha256', $raw); + $r['token_prefix'] = substr($raw, 0, 16); + } + + /** + * Inject the raw token into the response and emit MCP_TOKEN_ISSUE. + * + * @param array|null $r the response payload, mutated in place + */ + private function finalizeIssuance($o, &$r): void + { + if (is_array($r) && $this->newRawToken !== null) { + $r['raw_token'] = $this->newRawToken; + } + + try { + \Kyte\Core\ActivityLogger::getInstance()->log( + 'MCP_TOKEN_ISSUE', + 'KyteMCPToken', + 'token_prefix', + (string)$o->token_prefix, + [ + 'scopes' => (string)$o->scopes, + 'expires_at' => (int)$o->expires_at, + 'ip_allowlist' => (string)($o->ip_allowlist ?? ''), + ], + 201, + 'issued', + null, + (int)$o->id + ); + } catch (\Throwable $e) { + error_log('KyteMCPTokenController: failed to log MCP_TOKEN_ISSUE - ' . $e->getMessage()); + } + + // Clear the cached raw so a follow-up new() call in the same + // request lifecycle doesn't accidentally inherit the prior value. + $this->newRawToken = null; + } + + private function logRevoke($o): void + { + try { + \Kyte\Core\ActivityLogger::getInstance()->log( + 'MCP_TOKEN_REVOKE', + 'KyteMCPToken', + 'token_prefix', + (string)$o->token_prefix, + [ + 'scopes' => (string)$o->scopes, + 'last_used_at' => (int)($o->last_used_at ?? 0), + 'last_used_ip' => (string)($o->last_used_ip ?? ''), + ], + 200, + 'revoked', + null, + (int)$o->id + ); + } catch (\Throwable $e) { + error_log('KyteMCPTokenController: failed to log MCP_TOKEN_REVOKE - ' . $e->getMessage()); + } + } + + /** + * Format: `kmcp_live_<32 base62 chars>`. Matches the constant + * McpTokenStrategy uses to identify MCP tokens at auth time. + * + * Uses random_bytes (CSPRNG) and trims to base62 — concentrated + * entropy, no padding chars that could trip URL-encoding. + */ + private static function generateRawToken(): string + { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + $alphabetLen = strlen($alphabet); + $body = ''; + $bytes = random_bytes(32); + for ($i = 0; $i < 32; $i++) { + $body .= $alphabet[ord($bytes[$i]) % $alphabetLen]; + } + return \Kyte\Core\Auth\McpTokenStrategy::TOKEN_PREFIX . $body; + } +} diff --git a/src/Mvc/Model/Application.php b/src/Mvc/Model/Application.php index 7a23ed19..ce8b8467 100644 --- a/src/Mvc/Model/Application.php +++ b/src/Mvc/Model/Application.php @@ -179,6 +179,23 @@ 'protected' => true, ], + // Auth mode for code that Shipyard generates for this app. + // 'hmac' (default) → generated pages use the v1.x HMAC sign/rotate + // flow; new Kyte(url, key, iden, num, app). + // 'jwt' → generated pages use the v2 JWT flow; + // new Kyte(url, null, null, null, app, + // { authMode: 'jwt' }). + // Switching mid-flight is a deliberate migration step — both + // strategies coexist on the server, but each generated page is + // pinned to whichever mode was active at the time of build. + 'auth_mode' => [ + 'type' => 's', + 'required' => false, + 'size' => 16, + 'date' => false, + 'default' => 'hmac', + ], + // framework attributes 'kyte_account' => [ diff --git a/src/Mvc/Model/Controller.php b/src/Mvc/Model/Controller.php index c4d9fd37..da386bd9 100644 --- a/src/Mvc/Model/Controller.php +++ b/src/Mvc/Model/Controller.php @@ -51,6 +51,19 @@ ], ], + // Sensitive-data flag. When 1, the controller's request body and + // response payload are dropped from activity/error logs and MCP read + // tools refuse or redact source. Runtime API response is unaffected. + // Applies to virtual (no-model) controllers as well as model-bound ones. + 'sensitive' => [ + 'type' => 'i', + 'required' => false, + 'size' => 1, + 'unsigned' => true, + 'default' => 0, + 'date' => false, + ], + // framework attributes 'kyte_locked' => [ diff --git a/src/Mvc/Model/DataModel.php b/src/Mvc/Model/DataModel.php index a9150a24..02b41374 100644 --- a/src/Mvc/Model/DataModel.php +++ b/src/Mvc/Model/DataModel.php @@ -100,6 +100,19 @@ ], ], + // Sensitive-data flag. When 1, any row of this model triggers + // body+response drop in activity/error logs and MCP read_model omits + // the model definition (or returns redacted). Distinct from per-field + // ModelAttribute.sensitive which redacts named fields only. + 'sensitive' => [ + 'type' => 'i', + 'required' => false, + 'size' => 1, + 'unsigned' => true, + 'default' => 0, + 'date' => false, + ], + // framework attributes 'kyte_locked' => [ diff --git a/src/Mvc/Model/KyteMCPToken.php b/src/Mvc/Model/KyteMCPToken.php new file mode 100644 index 00000000..b2548d52 --- /dev/null +++ b/src/Mvc/Model/KyteMCPToken.php @@ -0,0 +1,186 @@ + 'KyteMCPToken', + 'struct' => [ + // The sha256 of the raw token (hex, 64 chars). Only the hash is stored; + // the raw token is shown once at creation and never recoverable. + // `protected` keeps the hash out of list/get responses — knowing the + // hash doesn't grant access (it's a hash, not a key) but there's no + // reason to surface it; UI shows the prefix instead. + 'token_hash' => [ + 'type' => 's', + 'required' => true, + 'size' => 64, + 'date' => false, + 'protected' => true, + ], + + // First ~12 chars of raw token (e.g. "kmcp_live_abcd"). Displayed in + // Shipyard UI so users can identify tokens at a glance. + 'token_prefix' => [ + 'type' => 's', + 'required' => true, + 'size' => 16, + 'date' => false, + ], + + // Human-facing label ("Claude Code - laptop", "CI runner", etc.) + 'name' => [ + 'type' => 's', + 'required' => true, + 'size' => 255, + 'date' => false, + ], + + // Scoping: which app this token can act on. Nullable for account-wide + // tokens (not expected in Phase 2, reserved for future use). + 'application' => [ + 'type' => 'i', + 'required' => false, + 'size' => 11, + 'unsigned' => true, + 'date' => false, + 'fk' => [ + 'model' => 'Application', + 'field' => 'id', + ], + ], + + // Comma-separated scopes: any combination of "read", "draft", "commit". + // Default on issuance is "read,draft". "commit" requires explicit opt-in. + 'scopes' => [ + 'type' => 's', + 'required' => true, + 'size' => 255, + 'date' => false, + ], + + // Expiration (unix epoch). 0 means never — discouraged; UI should default to 30d. + 'expires_at' => [ + 'type' => 'i', + 'required' => true, + 'size' => 11, + 'unsigned' => true, + 'default' => 0, + 'date' => true, + ], + + // Last-observed use (unix epoch). Updated asynchronously per validated request. + 'last_used_at' => [ + 'type' => 'i', + 'required' => false, + 'size' => 11, + 'unsigned' => true, + 'default' => 0, + 'date' => true, + ], + + // Last-observed source IP. IPv4 or IPv6 text form. + 'last_used_ip' => [ + 'type' => 's', + 'required' => false, + 'size' => 45, + 'date' => false, + ], + + // Optional CIDR allowlist (comma-separated). Empty = any source IP. + 'ip_allowlist' => [ + 'type' => 't', + 'required' => false, + 'date' => false, + ], + + // Revocation timestamp (unix epoch). 0 = active, nonzero = revoked at that time. + 'revoked_at' => [ + 'type' => 'i', + 'required' => false, + 'size' => 11, + 'unsigned' => true, + 'default' => 0, + 'date' => true, + ], + + // framework attributes + + 'kyte_account' => [ + 'type' => 'i', + 'required' => true, + 'size' => 11, + 'unsigned' => true, + 'date' => false, + ], + + // audit attributes + + 'created_by' => [ + 'type' => 'i', + 'required' => false, + 'date' => false, + ], + + 'date_created' => [ + 'type' => 'i', + 'required' => false, + 'date' => true, + ], + + 'modified_by' => [ + 'type' => 'i', + 'required' => false, + 'date' => false, + ], + + 'date_modified' => [ + 'type' => 'i', + 'required' => false, + 'date' => true, + ], + + 'deleted_by' => [ + 'type' => 'i', + 'required' => false, + 'date' => false, + ], + + 'date_deleted' => [ + 'type' => 'i', + 'required' => false, + 'date' => true, + ], + + 'deleted' => [ + 'type' => 'i', + 'required' => false, + 'size' => 1, + 'unsigned' => true, + 'default' => 0, + 'date' => false, + ], + ], +]; diff --git a/src/Mvc/Model/KytePage.php b/src/Mvc/Model/KytePage.php index 6a3887f8..72f1cdaa 100644 --- a/src/Mvc/Model/KytePage.php +++ b/src/Mvc/Model/KytePage.php @@ -118,6 +118,21 @@ 'date' => false, ], + // Sensitive-data flag. When 1, MCP read_page withholds the + // page's html/stylesheet/javascript content; metadata still + // returns so callers know the page exists. Distinct from the + // Controller/DataModel/ModelAttribute tiers because pages do + // not write to activity/error logs — this flag only affects + // MCP exposure. + 'sensitive' => [ + 'type' => 'i', + 'required' => false, + 'size' => 1, + 'unsigned' => true, + 'default' => 0, + 'date' => false, + ], + 'sitemap_include' => [ 'type' => 'i', 'required' => false, diff --git a/src/Mvc/Model/KyteRefreshToken.php b/src/Mvc/Model/KyteRefreshToken.php new file mode 100644 index 00000000..8d8e0129 --- /dev/null +++ b/src/Mvc/Model/KyteRefreshToken.php @@ -0,0 +1,210 @@ + 'KyteRefreshToken', + 'struct' => [ + // sha256 of the raw refresh token. Only the hash is stored; the + // raw token is returned once at issuance and never recoverable. + 'token_hash' => [ + 'type' => 's', + 'required' => true, + 'size' => 64, + 'date' => false, + 'protected' => true, + ], + + // First ~16 chars of the raw token (e.g. "kref_v1_abcdef"). Surfaced + // in admin tooling and audit logs so a row is identifiable without + // possession of the raw token. + 'token_prefix' => [ + 'type' => 's', + 'required' => true, + 'size' => 32, + 'date' => false, + ], + + // 64-char hex identifying the rotation family. All tokens descended + // from a single login share one family — reuse detection revokes the + // entire family when a revoked token is presented again. + 'token_family' => [ + 'type' => 's', + 'required' => true, + 'size' => 64, + 'date' => false, + ], + + // Owning user. FK to whichever user model the app is configured with; + // not declared as a strict FK here because it can target KyteUser or + // an app-specific user table. user_model on the application row + // determines which. + 'user' => [ + 'type' => 'i', + 'required' => true, + 'size' => 11, + 'unsigned' => true, + 'date' => false, + ], + + // Optional application scope. Nullable for account-wide refresh tokens + // (Shipyard-style admin sessions that aren't bound to a single app). + 'application' => [ + 'type' => 'i', + 'required' => false, + 'size' => 11, + 'unsigned' => true, + 'date' => false, + 'fk' => [ + 'model' => 'Application', + 'field' => 'id', + ], + ], + + // Expiration (unix epoch). 0 means never — strongly discouraged; UI + // defaults to KYTE_JWT_REFRESH_TTL (7 days). + 'expires_at' => [ + 'type' => 'i', + 'required' => true, + 'size' => 11, + 'unsigned' => true, + 'default' => 0, + 'date' => true, + ], + + // Last-observed use (unix epoch). Updated on every rotation attempt. + 'last_used_at' => [ + 'type' => 'i', + 'required' => false, + 'size' => 11, + 'unsigned' => true, + 'default' => 0, + 'date' => true, + ], + + // Last-observed source IP (IPv4 or IPv6 text). Captured at rotation + // for forensic correlation. + 'last_used_ip' => [ + 'type' => 's', + 'required' => false, + 'size' => 45, + 'date' => false, + ], + + // Revocation timestamp (unix epoch). 0 = active, nonzero = revoked. + 'revoked_at' => [ + 'type' => 'i', + 'required' => false, + 'size' => 11, + 'unsigned' => true, + 'default' => 0, + 'date' => true, + ], + + // Why this token was revoked. Common values: 'rotated' (normal + // rotation), 'reuse_detected' (presented after revocation — leak + // signal), 'logout', 'admin_revoke', 'expired'. + 'revoked_reason' => [ + 'type' => 's', + 'required' => false, + 'size' => 64, + 'date' => false, + ], + + // Successor token id when this token has been rotated. 0 while + // active. Provides an audit trail of the rotation chain. + 'rotated_to' => [ + 'type' => 'i', + 'required' => false, + 'size' => 11, + 'unsigned' => true, + 'default' => 0, + 'date' => false, + ], + + // framework attributes + + 'kyte_account' => [ + 'type' => 'i', + 'required' => true, + 'size' => 11, + 'unsigned' => true, + 'date' => false, + ], + + // audit attributes + + 'created_by' => [ + 'type' => 'i', + 'required' => false, + 'date' => false, + ], + + 'date_created' => [ + 'type' => 'i', + 'required' => false, + 'date' => true, + ], + + 'modified_by' => [ + 'type' => 'i', + 'required' => false, + 'date' => false, + ], + + 'date_modified' => [ + 'type' => 'i', + 'required' => false, + 'date' => true, + ], + + 'deleted_by' => [ + 'type' => 'i', + 'required' => false, + 'date' => false, + ], + + 'date_deleted' => [ + 'type' => 'i', + 'required' => false, + 'date' => true, + ], + + 'deleted' => [ + 'type' => 'i', + 'required' => false, + 'size' => 1, + 'unsigned' => true, + 'default' => 0, + 'date' => false, + ], + ], +]; diff --git a/src/Mvc/Model/ModelAttribute.php b/src/Mvc/Model/ModelAttribute.php index f554f781..0758d276 100644 --- a/src/Mvc/Model/ModelAttribute.php +++ b/src/Mvc/Model/ModelAttribute.php @@ -55,6 +55,19 @@ 'date' => false, ], + // Sensitive-data flag at the field level. Distinct from 'protected': + // 'protected' only blanks the value in GET responses. 'sensitive' + // redacts the field from activity/error logs and from MCP read tool + // responses. Set both if you want both behaviors. + 'sensitive' => [ + 'type' => 'i', + 'required' => false, + 'size' => 1, + 'unsigned' => true, + 'default' => 0, + 'date' => false, + ], + // optional description 'description' => [ 'type' => 't', diff --git a/tests/ActivityLoggerSensitivityTest.php b/tests/ActivityLoggerSensitivityTest.php new file mode 100644 index 00000000..2d894695 --- /dev/null +++ b/tests/ActivityLoggerSensitivityTest.php @@ -0,0 +1,262 @@ +data` into `KyteActivityLog.request_data` — fine + * for most controllers, but undesirable when the controller is + * declared as a non-storing pass-through. The Controller.sensitive + * flag exists to opt those controllers out of body capture. + * + * The test below replays exactly that scenario: + * testSensitiveControllerDropsRequestBody — virtual controller named + * 'AlSensTestVirtualCtrl' with sensitive=1, a request body + * containing 'regulated text that must not be stored', + * asserts the resulting KyteActivityLog row has request_data NULL. + * + * The remaining tests cover the other three SensitivityPolicy tiers + * (DataModel.sensitive, ModelAttribute.sensitive, the baseline + * SENSITIVE_FIELDS hardcoded list), plus the PUT changes diff which + * is a second redaction path that needs the same gating. + * + * Why these tests rather than an end-to-end Api::route() drive: + * The leak surface is ActivityLogger::log(); Api::route() at line + * ~763 is a one-line pass-through that forwards $this->model and + * $this->data to it. The integration here exercises the actual + * policy + log code paths against the real KyteActivityLog table. + * A full Api::route() drive would require HMAC auth scaffolding, + * add brittleness against router refactors, and exercise only that + * single extra line of forwarding code — bad ROI. + * + * Matrix: + * - Controller-only flag (no-model controller case) → request_data null + * - Model flag set → request_data null + * and PUT changes diff null + * - Field flag set, model not flagged → flagged field redacted, + * other fields preserved + * - No flags → baseline behavior preserved + * (hardcoded SENSITIVE_FIELDS + * still runs) + * - PUT changes with field flag → flagged field's old/new + * both '[REDACTED]' + * + * Drives the real ActivityLogger::log() call against the real + * KyteActivityLog table; reads the row back to assert what was stored. + */ +class ActivityLoggerSensitivityTest extends TestCase +{ + private const ACCOUNT = 'al-sens-test'; + private const SENS_CTRL = 'AlSensTestVirtualCtrl'; + private const SENS_MODEL = 'AlSensTestSensitiveModel'; + private const PLAIN_MODEL = 'AlSensTestPlainModel'; + + private Api $api; + private int $accountId; + + protected function setUp(): void + { + \Kyte\Core\DBI::dbInit(KYTE_DB_USERNAME, KYTE_DB_PASSWORD, KYTE_DB_HOST, KYTE_DB_DATABASE, KYTE_DB_CHARSET, 'InnoDB'); + + $this->api = new Api(); + + \Kyte\Core\DBI::createTable(KyteAccount); + \Kyte\Core\DBI::createTable(Application); + \Kyte\Core\DBI::createTable(Controller); + \Kyte\Core\DBI::createTable(DataModel); + \Kyte\Core\DBI::createTable(ModelAttribute); + \Kyte\Core\DBI::createTable(KyteActivityLog); + + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number = '" . self::ACCOUNT . "'"); + \Kyte\Core\DBI::query("DELETE FROM `Controller` WHERE name LIKE 'AlSensTest%'"); + \Kyte\Core\DBI::query("DELETE FROM `DataModel` WHERE name LIKE 'AlSensTest%'"); + \Kyte\Core\DBI::query("DELETE FROM `ModelAttribute` WHERE name LIKE 'al_sens_test_%'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteActivityLog` WHERE model_name LIKE 'AlSensTest%'"); + + $acct = new \Kyte\Core\ModelObject(KyteAccount); + $acct->create(['number' => self::ACCOUNT, 'name' => 'AL Sens Test']); + $this->accountId = (int)$acct->id; + + // Sensitive controller (no associated model — simulates a + // pass-through endpoint whose body contents are regulated). + $ctrl = new \Kyte\Core\ModelObject(Controller); + $ctrl->create([ + 'name' => self::SENS_CTRL, + 'sensitive' => 1, + 'kyte_account' => $this->accountId, + ]); + + // Sensitive model. + $sensModel = new \Kyte\Core\ModelObject(DataModel); + $sensModel->create([ + 'name' => self::SENS_MODEL, + 'sensitive' => 1, + 'kyte_account' => $this->accountId, + ]); + + // Plain model with one sensitive field. + $plainModel = new \Kyte\Core\ModelObject(DataModel); + $plainModel->create([ + 'name' => self::PLAIN_MODEL, + 'sensitive' => 0, + 'kyte_account' => $this->accountId, + ]); + $plainModelId = (int)$plainModel->id; + + $attr = new \Kyte\Core\ModelObject(ModelAttribute); + $attr->create([ + 'name' => 'al_sens_test_secretvalue', + 'type' => 's', + 'dataModel' => $plainModelId, + 'sensitive' => 1, + 'kyte_account' => $this->accountId, + ]); + + SensitivityPolicy::resetForTests(); + + // Set ActivityLogger context manually (we're not going through Api). + $this->api->account = new \Kyte\Core\ModelObject(KyteAccount); + $this->api->account->retrieve('id', $this->accountId); + ActivityLogger::getInstance()->setContext($this->api); + } + + public function testSensitiveControllerDropsRequestBody(): void + { + ActivityLogger::getInstance()->log( + 'POST', self::SENS_CTRL, null, null, + ['payload' => 'regulated text that must not be stored'], + 200, 'success' + ); + + $row = $this->latestLogRow(self::SENS_CTRL); + $this->assertNotNull($row, 'log row was written'); + $this->assertNull($row['request_data'], 'sensitive controller → request_data dropped'); + $this->assertSame('POST', $row['action']); + $this->assertSame(200, (int)$row['response_code']); + } + + public function testSensitiveModelDropsRequestBody(): void + { + ActivityLogger::getInstance()->log( + 'POST', self::SENS_MODEL, null, null, + ['some_field' => 'regulated value', 'note' => 'also dropped'], + 200, 'success' + ); + + $row = $this->latestLogRow(self::SENS_MODEL); + $this->assertNotNull($row); + $this->assertNull($row['request_data']); + } + + public function testSensitiveFieldRedactedOnPlainModel(): void + { + ActivityLogger::getInstance()->log( + 'POST', self::PLAIN_MODEL, null, null, + [ + 'al_sens_test_secretvalue' => 'redact-me', + 'note' => 'keep this', + ], + 200, 'success' + ); + + $row = $this->latestLogRow(self::PLAIN_MODEL); + $this->assertNotNull($row); + $decoded = json_decode($row['request_data'], true); + $this->assertSame('[REDACTED]', $decoded['al_sens_test_secretvalue']); + $this->assertSame('keep this', $decoded['note']); + } + + public function testHardcodedRedactionStillRunsForUnflaggedModel(): void + { + // The hardcoded SENSITIVE_FIELDS list (password, token, etc.) must + // still apply even when no policy flags are set. This is the + // pre-existing baseline behavior. + ActivityLogger::getInstance()->log( + 'POST', self::PLAIN_MODEL, null, null, + [ + 'password' => 'hunter2', + 'note' => 'keep', + ], + 200, 'success' + ); + + $row = $this->latestLogRow(self::PLAIN_MODEL); + $this->assertNotNull($row); + $decoded = json_decode($row['request_data'], true); + $this->assertSame('[REDACTED]', $decoded['password']); + $this->assertSame('keep', $decoded['note']); + } + + public function testPutChangesDroppedWhenModelSensitive(): void + { + $logger = ActivityLogger::getInstance(); + + // Simulate the pre-update capture that Api.php would have invoked. + $reflection = new \ReflectionClass($logger); + $prop = $reflection->getProperty('preUpdateState'); + $prop->setAccessible(true); + $prop->setValue($logger, ['some_field' => 'old', 'other' => 'unchanged']); + + $logger->log( + 'PUT', self::SENS_MODEL, 'id', 1, + ['some_field' => 'new'], + 200, 'success' + ); + + $row = $this->latestLogRow(self::SENS_MODEL); + $this->assertNotNull($row); + $this->assertNull($row['changes'], 'sensitive model → changes diff dropped'); + $this->assertNull($row['request_data']); + } + + public function testPutChangesRedactsSensitiveFieldsOnPlainModel(): void + { + $logger = ActivityLogger::getInstance(); + + $reflection = new \ReflectionClass($logger); + $prop = $reflection->getProperty('preUpdateState'); + $prop->setAccessible(true); + $prop->setValue($logger, [ + 'al_sens_test_secretvalue' => 'old-secret', + 'note' => 'old-note', + ]); + + $logger->log( + 'PUT', self::PLAIN_MODEL, 'id', 1, + [ + 'al_sens_test_secretvalue' => 'new-secret', + 'note' => 'new-note', + ], + 200, 'success' + ); + + $row = $this->latestLogRow(self::PLAIN_MODEL); + $this->assertNotNull($row); + $changes = json_decode($row['changes'], true); + $this->assertSame('[REDACTED]', $changes['al_sens_test_secretvalue']['old']); + $this->assertSame('[REDACTED]', $changes['al_sens_test_secretvalue']['new']); + $this->assertSame('old-note', $changes['note']['old']); + $this->assertSame('new-note', $changes['note']['new']); + } + + private function latestLogRow(string $modelName): ?array + { + $escaped = str_replace("'", "''", $modelName); + $rows = \Kyte\Core\DBI::query( + "SELECT * FROM `KyteActivityLog` WHERE model_name = '$escaped' ORDER BY id DESC LIMIT 1" + ); + if (!is_array($rows) || count($rows) === 0) { + return null; + } + return $rows[0]; + } +} diff --git a/tests/Bz2CodecTest.php b/tests/Bz2CodecTest.php new file mode 100644 index 00000000..3807945f --- /dev/null +++ b/tests/Bz2CodecTest.php @@ -0,0 +1,46 @@ +assertSame($original, Bz2Codec::decompressIfBz2($compressed)); + } + + public function testReturnsRawDataWhenNotBz2(): void + { + $plain = "// already decompressed source\nfunction foo() {}\n"; + $this->assertSame($plain, Bz2Codec::decompressIfBz2($plain)); + } + + public function testHandlesEmptyAndNullInput(): void + { + $this->assertSame('', Bz2Codec::decompressIfBz2(null)); + $this->assertSame('', Bz2Codec::decompressIfBz2('')); + $this->assertSame('B', Bz2Codec::decompressIfBz2('B')); // single byte, can't be BZ + } + + public function testReturnsRawDataWhenBz2DecompressionFails(): void + { + // Looks like bz2 (BZ prefix) but is corrupted — should fall back to + // returning the raw blob rather than blanking the field. + $broken = "BZ" . str_repeat("\x00", 30); + $result = Bz2Codec::decompressIfBz2($broken); + $this->assertSame($broken, $result); + } +} diff --git a/tests/ClientIpTest.php b/tests/ClientIpTest.php new file mode 100644 index 00000000..e24ddb5d --- /dev/null +++ b/tests/ClientIpTest.php @@ -0,0 +1,106 @@ +assertSame('203.0.113.10', ClientIp::resolve()); + } + + public function testIgnoresProxyHeadersWhenTrustGateDisabled(): void + { + // We can't guarantee the constant isn't defined (test ordering), + // so this test only runs when it isn't. + if (defined('KYTE_TRUST_PROXY_IP_HEADERS') && KYTE_TRUST_PROXY_IP_HEADERS === true) { + $this->markTestSkipped('KYTE_TRUST_PROXY_IP_HEADERS already defined as true; cannot exercise the disabled branch.'); + } + + $_SERVER['REMOTE_ADDR'] = '198.51.100.5'; + $_SERVER['HTTP_CF_CONNECTING_IP'] = '203.0.113.99'; // would-be-spoofed + $_SERVER['HTTP_X_FORWARDED_FOR'] = '203.0.113.99, 198.51.100.5'; + + $this->assertSame('198.51.100.5', ClientIp::resolve(), + 'Without trust gate, proxy headers must not override REMOTE_ADDR (anti-spoofing default)'); + } + + public function testHonorsCfConnectingIpWhenTrustGateEnabled(): void + { + if (!defined('KYTE_TRUST_PROXY_IP_HEADERS')) { + define('KYTE_TRUST_PROXY_IP_HEADERS', true); + } + if (KYTE_TRUST_PROXY_IP_HEADERS !== true) { + $this->markTestSkipped('KYTE_TRUST_PROXY_IP_HEADERS already defined to a non-true value.'); + } + + $_SERVER['REMOTE_ADDR'] = '172.71.28.165'; // Cloudflare edge + $_SERVER['HTTP_CF_CONNECTING_IP'] = '203.0.113.42'; + + $this->assertSame('203.0.113.42', ClientIp::resolve()); + } + + public function testHonorsFirstXffHopWhenCfHeaderAbsent(): void + { + if (!defined('KYTE_TRUST_PROXY_IP_HEADERS')) { + define('KYTE_TRUST_PROXY_IP_HEADERS', true); + } + if (KYTE_TRUST_PROXY_IP_HEADERS !== true) { + $this->markTestSkipped('KYTE_TRUST_PROXY_IP_HEADERS already defined to a non-true value.'); + } + + $_SERVER['REMOTE_ADDR'] = '10.0.0.5'; // ALB IP + $_SERVER['HTTP_X_FORWARDED_FOR'] = '203.0.113.42, 10.0.0.1, 10.0.0.5'; + + $this->assertSame('203.0.113.42', ClientIp::resolve(), + 'First XFF hop is the original client; later entries are proxies'); + } + + public function testFallsBackToRemoteAddrWhenProxyHeadersMalformed(): void + { + if (!defined('KYTE_TRUST_PROXY_IP_HEADERS')) { + define('KYTE_TRUST_PROXY_IP_HEADERS', true); + } + if (KYTE_TRUST_PROXY_IP_HEADERS !== true) { + $this->markTestSkipped('KYTE_TRUST_PROXY_IP_HEADERS already defined to a non-true value.'); + } + + $_SERVER['REMOTE_ADDR'] = '198.51.100.5'; + $_SERVER['HTTP_CF_CONNECTING_IP'] = 'definitely-not-an-ip'; + $_SERVER['HTTP_X_FORWARDED_FOR'] = 'also-not-an-ip, more-garbage'; + + $this->assertSame('198.51.100.5', ClientIp::resolve(), + 'Malformed proxy headers must fall back rather than corrupting the IP'); + } + + public function testReturnsEmptyStringWhenNothingAvailable(): void + { + unset($_SERVER['REMOTE_ADDR']); + $this->assertSame('', ClientIp::resolve()); + } +} diff --git a/tests/Dockerfile b/tests/Dockerfile index 2c322f2d..5d305d95 100644 --- a/tests/Dockerfile +++ b/tests/Dockerfile @@ -6,7 +6,8 @@ RUN apt-get update \ unzip \ libcurl4-openssl-dev \ libonig-dev \ - && docker-php-ext-install mysqli curl mbstring \ + libbz2-dev \ + && docker-php-ext-install mysqli curl mbstring bz2 \ && rm -rf /var/lib/apt/lists/* COPY --from=composer:2 /usr/bin/composer /usr/bin/composer diff --git a/tests/ErrorHandlerSensitivityTest.php b/tests/ErrorHandlerSensitivityTest.php new file mode 100644 index 00000000..b4f5a2b8 --- /dev/null +++ b/tests/ErrorHandlerSensitivityTest.php @@ -0,0 +1,263 @@ +api = new Api(); + + \Kyte\Core\DBI::createTable(KyteAccount); + \Kyte\Core\DBI::createTable(Application); + \Kyte\Core\DBI::createTable(Controller); + \Kyte\Core\DBI::createTable(DataModel); + \Kyte\Core\DBI::createTable(ModelAttribute); + \Kyte\Core\DBI::createTable(KyteError); + + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number = '" . self::ACCOUNT . "'"); + \Kyte\Core\DBI::query("DELETE FROM `Controller` WHERE name LIKE 'EhSensTest%'"); + \Kyte\Core\DBI::query("DELETE FROM `DataModel` WHERE name LIKE 'EhSensTest%'"); + \Kyte\Core\DBI::query("DELETE FROM `ModelAttribute` WHERE name LIKE 'eh_sens_test_%'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteError` WHERE model LIKE 'EhSensTest%'"); + + $acct = new \Kyte\Core\ModelObject(KyteAccount); + $acct->create(['number' => self::ACCOUNT, 'name' => 'EH Sens Test']); + $this->accountId = (int)$acct->id; + + $ctrl = new \Kyte\Core\ModelObject(Controller); + $ctrl->create([ + 'name' => self::SENS_CTRL, + 'sensitive' => 1, + 'kyte_account' => $this->accountId, + ]); + + $sensModel = new \Kyte\Core\ModelObject(DataModel); + $sensModel->create([ + 'name' => self::SENS_MODEL, + 'sensitive' => 1, + 'kyte_account' => $this->accountId, + ]); + + $plainModel = new \Kyte\Core\ModelObject(DataModel); + $plainModel->create([ + 'name' => self::PLAIN_MODEL, + 'sensitive' => 0, + 'kyte_account' => $this->accountId, + ]); + $plainModelId = (int)$plainModel->id; + + $attr = new \Kyte\Core\ModelObject(ModelAttribute); + $attr->create([ + 'name' => 'eh_sens_test_secretvalue', + 'type' => 's', + 'dataModel' => $plainModelId, + 'sensitive' => 1, + 'kyte_account' => $this->accountId, + ]); + + SensitivityPolicy::resetForTests(); + $this->resetErrorHandlerSingleton(); + } + + public function testSensitiveControllerDropsDataAndResponse(): void + { + $context = $this->buildContext(self::SENS_CTRL); + $context->data = ['payload' => 'regulated text that must not be stored']; + $context->response = ['returned' => 'also regulated']; + + $handler = ErrorHandler::getInstance($context); + $handler->handleException(new \RuntimeException('boom')); + + $row = $this->latestErrorRow(self::SENS_CTRL); + $this->assertNotNull($row); + $this->assertNull($row['data'], 'sensitive controller → data dropped'); + $this->assertNull($row['response'], 'sensitive controller → response dropped'); + $this->assertSame('boom', $row['message'], 'metadata preserved'); + } + + public function testSensitiveModelDropsDataAndResponse(): void + { + $context = $this->buildContext(self::SENS_MODEL); + $context->data = ['some_field' => 'regulated']; + $context->response = ['out' => 'also regulated']; + + $handler = ErrorHandler::getInstance($context); + $handler->handleException(new \RuntimeException('kaboom')); + + $row = $this->latestErrorRow(self::SENS_MODEL); + $this->assertNotNull($row); + $this->assertNull($row['data']); + $this->assertNull($row['response']); + } + + public function testFieldFlagRedactsOnPlainModel(): void + { + $context = $this->buildContext(self::PLAIN_MODEL); + $context->data = [ + 'eh_sens_test_secretvalue' => 'redact-me', + 'note' => 'keep', + ]; + $context->response = [ + 'eh_sens_test_secretvalue' => 'also-redact', + 'status' => 'ok', + ]; + + $handler = ErrorHandler::getInstance($context); + $handler->handleException(new \RuntimeException('partial')); + + $row = $this->latestErrorRow(self::PLAIN_MODEL); + $this->assertNotNull($row); + $this->assertStringContainsString('[REDACTED]', $row['data']); + $this->assertStringContainsString('keep', $row['data']); + $this->assertStringNotContainsString('redact-me', $row['data']); + $this->assertStringContainsString('[REDACTED]', $row['response']); + $this->assertStringContainsString('ok', $row['response']); + $this->assertStringNotContainsString('also-redact', $row['response']); + } + + public function testNoFlagsPreservesExistingBehavior(): void + { + $context = $this->buildContext(self::PLAIN_MODEL); + $context->data = ['note' => 'no flags here']; + $context->response = ['status' => 'ok']; + + $handler = ErrorHandler::getInstance($context); + $handler->handleException(new \RuntimeException('baseline')); + + $row = $this->latestErrorRow(self::PLAIN_MODEL); + $this->assertNotNull($row); + $this->assertStringContainsString('no flags here', $row['data']); + $this->assertStringContainsString('ok', $row['response']); + } + + public function testAIDefenseInDepthGateBlocksSensitiveContext(): void + { + // Drive AIErrorCorrection::queueForAnalysis directly with a + // sensitive context. The first thing the function should do is + // consult the policy and return early. We assert this by + // confirming no AIErrorAnalysis row was written for our test + // account (the function would have created or updated one if it + // had progressed past the gate). + + \Kyte\Core\DBI::createTable(KyteError); + + $error = new \Kyte\Core\ModelObject(KyteError); + $error->create([ + 'kyte_account' => $this->accountId, + 'message' => 'sensitive-context error', + 'model' => self::SENS_CTRL, + 'log_level' => 'critical', + ]); + + $context = $this->buildContext(self::SENS_CTRL); + $context->appId = 1; + + // Should return early without throwing or writing AI rows. If the + // gate were missing, the function would proceed and fail trying + // to load AI config / write to AI tables. + \Kyte\AI\AIErrorCorrection::queueForAnalysis($error, $context); + + // Smoke assertion: the call completed without throwing. The real + // verification is the error_log message, but PHPUnit doesn't have + // a clean assertion for that without output buffering. + $this->assertTrue(true); + } + + private function buildContext(string $modelName): object + { + $context = new \stdClass(); + $context->model = $modelName; + $context->account = new \stdClass(); + $context->account->id = $this->accountId; + $context->user = null; + $context->appId = null; + $context->key = null; + $context->signature = null; + $context->contentType = null; + $context->request = 'POST'; + $context->field = null; + $context->value = null; + $context->data = null; + $context->response = null; + return $context; + } + + private function resetErrorHandlerSingleton(): void + { + $reflection = new \ReflectionClass(ErrorHandler::class); + $prop = $reflection->getProperty('instance'); + $prop->setAccessible(true); + $prop->setValue(null, null); + } + + private function latestErrorRow(string $modelName): ?array + { + $escaped = str_replace("'", "''", $modelName); + $rows = \Kyte\Core\DBI::query( + "SELECT * FROM `KyteError` WHERE model = '$escaped' ORDER BY id DESC LIMIT 1" + ); + if (!is_array($rows) || count($rows) === 0) { + return null; + } + return $rows[0]; + } +} diff --git a/tests/JwtEndpointTest.php b/tests/JwtEndpointTest.php new file mode 100644 index 00000000..01b99d18 --- /dev/null +++ b/tests/JwtEndpointTest.php @@ -0,0 +1,281 @@ +api = new Api(); + + \Kyte\Core\DBI::createTable(KyteAccount); + \Kyte\Core\DBI::createTable(KyteUser); + \Kyte\Core\DBI::createTable(KyteRefreshToken); + + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number = '" . self::ACCOUNT . "'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteUser` WHERE email = 'jwt-endpoint-test@example.com'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteRefreshToken` WHERE token_prefix LIKE 'kref_v1_%'"); + + $acct = new \Kyte\Core\ModelObject(KyteAccount); + $acct->create(['number' => self::ACCOUNT, 'name' => 'JWT Endpoint Test']); + $this->accountId = (int)$acct->id; + + $this->userEmail = 'jwt-endpoint-test@example.com'; + $user = new \Kyte\Core\ModelObject(KyteUser); + $user->create([ + 'name' => 'JWT Endpoint Test User', + 'email' => $this->userEmail, + 'password' => password_hash(self::PASSWORD, PASSWORD_DEFAULT), + 'kyte_account' => $this->accountId, + ]); + $this->userId = (int)$user->id; + + if (!defined('KYTE_JWT_SECRET')) { + define('KYTE_JWT_SECRET', self::SECRET); + } + } + + public function testLoginWithValidCredentialsReturnsTokens(): void + { + $result = JwtEndpoint::process($this->api, $this->serverFor('/jwt/login'), $this->jsonBody([ + 'email' => $this->userEmail, + 'password' => self::PASSWORD, + ])); + + $this->assertSame(200, $result['status']); + $body = $result['body']; + $this->assertArrayHasKey('access_token', $body); + $this->assertArrayHasKey('refresh_token', $body); + $this->assertSame('Bearer', $body['token_type']); + $this->assertStringStartsWith('kref_v1_', $body['refresh_token']); + + // Access token should decode and claim our user/account. + $decoded = JWT::decode($body['access_token'], new Key(self::SECRET, 'HS256')); + $this->assertSame((string)$this->userId, (string)$decoded->sub); + $this->assertSame((string)$this->accountId, (string)$decoded->aud); + } + + public function testLoginWithMissingPasswordReturns400(): void + { + $result = JwtEndpoint::process($this->api, $this->serverFor('/jwt/login'), $this->jsonBody([ + 'email' => $this->userEmail, + ])); + + $this->assertSame(400, $result['status']); + $this->assertSame('invalid_request', $result['body']['error']); + } + + public function testLoginWithUnknownUserReturns401(): void + { + $result = JwtEndpoint::process($this->api, $this->serverFor('/jwt/login'), $this->jsonBody([ + 'email' => 'nobody@example.com', + 'password' => self::PASSWORD, + ])); + + $this->assertSame(401, $result['status']); + $this->assertSame('invalid_credentials', $result['body']['error']); + } + + public function testLoginWithWrongPasswordReturns401(): void + { + $result = JwtEndpoint::process($this->api, $this->serverFor('/jwt/login'), $this->jsonBody([ + 'email' => $this->userEmail, + 'password' => 'wrong-' . self::PASSWORD, + ])); + + $this->assertSame(401, $result['status']); + $this->assertSame('invalid_credentials', $result['body']['error']); + } + + public function testTwoLoginsProduceSeparateRefreshFamilies(): void + { + $r1 = JwtEndpoint::process($this->api, $this->serverFor('/jwt/login'), $this->jsonBody([ + 'email' => $this->userEmail, + 'password' => self::PASSWORD, + ])); + $r2 = JwtEndpoint::process($this->api, $this->serverFor('/jwt/login'), $this->jsonBody([ + 'email' => $this->userEmail, + 'password' => self::PASSWORD, + ])); + + $this->assertSame(200, $r1['status']); + $this->assertSame(200, $r2['status']); + $this->assertNotSame($r1['body']['refresh_token'], $r2['body']['refresh_token']); + + $f1 = $this->familyOf($r1['body']['refresh_token']); + $f2 = $this->familyOf($r2['body']['refresh_token']); + $this->assertNotSame($f1, $f2, 'separate logins must produce separate families'); + } + + public function testRefreshWithValidTokenRotates(): void + { + $login = $this->doLogin(); + $original = $login['refresh_token']; + + $refresh = JwtEndpoint::process($this->api, $this->serverFor('/jwt/refresh'), $this->jsonBody([ + 'refresh_token' => $original, + ])); + + $this->assertSame(200, $refresh['status']); + $this->assertArrayHasKey('access_token', $refresh['body']); + $this->assertNotSame($original, $refresh['body']['refresh_token']); + + // Original token must now refuse to refresh (revoked). + $reuse = JwtEndpoint::process($this->api, $this->serverFor('/jwt/refresh'), $this->jsonBody([ + 'refresh_token' => $original, + ])); + $this->assertSame(401, $reuse['status']); + } + + public function testRefreshReuseRevokesFamily(): void + { + $login = $this->doLogin(); + $original = $login['refresh_token']; + + // First refresh — successful rotation. + $rot = JwtEndpoint::process($this->api, $this->serverFor('/jwt/refresh'), $this->jsonBody([ + 'refresh_token' => $original, + ])); + $newToken = $rot['body']['refresh_token']; + + // Replay the original — leak signal, family-wide revoke. + $replay = JwtEndpoint::process($this->api, $this->serverFor('/jwt/refresh'), $this->jsonBody([ + 'refresh_token' => $original, + ])); + $this->assertSame(401, $replay['status']); + + // The successor token should now also be unusable. + $followup = JwtEndpoint::process($this->api, $this->serverFor('/jwt/refresh'), $this->jsonBody([ + 'refresh_token' => $newToken, + ])); + $this->assertSame(401, $followup['status']); + } + + public function testLogoutRevokesOnlyPresentedToken(): void + { + $login1 = $this->doLogin(); + $login2 = $this->doLogin(); + + $logout = JwtEndpoint::process($this->api, $this->serverFor('/jwt/logout'), $this->jsonBody([ + 'refresh_token' => $login1['refresh_token'], + ])); + $this->assertSame(200, $logout['status']); + + // login1 refresh should fail; login2 should still work. + $r1 = JwtEndpoint::process($this->api, $this->serverFor('/jwt/refresh'), $this->jsonBody([ + 'refresh_token' => $login1['refresh_token'], + ])); + $this->assertSame(401, $r1['status']); + + $r2 = JwtEndpoint::process($this->api, $this->serverFor('/jwt/refresh'), $this->jsonBody([ + 'refresh_token' => $login2['refresh_token'], + ])); + $this->assertSame(200, $r2['status'], 'unrelated session must keep working'); + } + + public function testLogoutAllRevokesAcrossFamilies(): void + { + $login1 = $this->doLogin(); + $login2 = $this->doLogin(); + $login3 = $this->doLogin(); + + $result = JwtEndpoint::process($this->api, $this->serverFor('/jwt/logout-all'), $this->jsonBody([ + 'refresh_token' => $login1['refresh_token'], + ])); + $this->assertSame(200, $result['status']); + $this->assertGreaterThanOrEqual(3, $result['body']['revoked']); + + // Each of the three refresh tokens should now fail. + foreach ([$login1, $login2, $login3] as $login) { + $r = JwtEndpoint::process($this->api, $this->serverFor('/jwt/refresh'), $this->jsonBody([ + 'refresh_token' => $login['refresh_token'], + ])); + $this->assertSame(401, $r['status']); + } + } + + public function testUnknownActionReturns404(): void + { + $result = JwtEndpoint::process($this->api, $this->serverFor('/jwt/wat'), ''); + $this->assertSame(404, $result['status']); + } + + public function testNonPostMethodReturns405(): void + { + $server = $this->serverFor('/jwt/login'); + $server['REQUEST_METHOD'] = 'GET'; + $result = JwtEndpoint::process($this->api, $server, ''); + $this->assertSame(405, $result['status']); + } + + private function doLogin(): array + { + $result = JwtEndpoint::process($this->api, $this->serverFor('/jwt/login'), $this->jsonBody([ + 'email' => $this->userEmail, + 'password' => self::PASSWORD, + ])); + $this->assertSame(200, $result['status'], 'precondition: login must succeed'); + return $result['body']; + } + + private function familyOf(string $rawRefreshToken): string + { + $hash = hash('sha256', $rawRefreshToken); + $rows = \Kyte\Core\DBI::query("SELECT token_family FROM `KyteRefreshToken` WHERE token_hash = '$hash'"); + $this->assertNotEmpty($rows); + return (string)$rows[0]['token_family']; + } + + private function serverFor(string $path): array + { + return [ + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => $path, + 'REMOTE_ADDR' => '203.0.113.42', + ]; + } + + private function jsonBody(array $payload): string + { + return json_encode($payload); + } +} diff --git a/tests/JwtSessionStrategyTest.php b/tests/JwtSessionStrategyTest.php new file mode 100644 index 00000000..8cb96b9b --- /dev/null +++ b/tests/JwtSessionStrategyTest.php @@ -0,0 +1,261 @@ + → false (wrong shape) + * - Bearer → true + * + * preAuth matrix: + * - Valid JWT for known user/account → Api state populated + * - Tampered signature → SessionException + * - Expired token → SessionException + * - Wrong issuer → SessionException + * - Unknown account → SessionException + * - Unknown user → SessionException + * - User/account mismatch → SessionException + */ +class JwtSessionStrategyTest extends TestCase +{ + private const ACCOUNT = 'jwt-strat-test'; + private const FALLBACK_SECRET = 'jwt-strat-test-secret-with-enough-entropy-12345'; + private const ISSUER = 'kyte'; + + private Api $api; + private int $accountId; + private int $userId; + + protected function setUp(): void + { + \Kyte\Core\DBI::dbInit(KYTE_DB_USERNAME, KYTE_DB_PASSWORD, KYTE_DB_HOST, KYTE_DB_DATABASE, KYTE_DB_CHARSET, 'InnoDB'); + + $this->api = new Api(); + + \Kyte\Core\DBI::createTable(KyteAccount); + \Kyte\Core\DBI::createTable(KyteUser); + + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number = '" . self::ACCOUNT . "'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteUser` WHERE email = 'jwt-strat-test@example.com'"); + + $acct = new \Kyte\Core\ModelObject(KyteAccount); + $acct->create(['number' => self::ACCOUNT, 'name' => 'JWT Strategy Test']); + $this->accountId = (int)$acct->id; + + $user = new \Kyte\Core\ModelObject(KyteUser); + $user->create([ + 'name' => 'JWT Test User', + 'email' => 'jwt-strat-test@example.com', + 'password' => password_hash('not-tested-here', PASSWORD_DEFAULT), + 'kyte_account' => $this->accountId, + ]); + $this->userId = (int)$user->id; + + // PHP constants are immutable — whichever test file runs first + // pins KYTE_JWT_SECRET. Read the actual value when minting so + // signature verification works regardless of test ordering. + if (!defined('KYTE_JWT_SECRET')) { + define('KYTE_JWT_SECRET', self::FALLBACK_SECRET); + } + + $_SERVER = ['REMOTE_ADDR' => '127.0.0.1']; + } + + public function testMatchesReturnsFalseWhenNoAuthorizationHeader(): void + { + $strategy = new JwtSessionStrategy(); + $this->assertFalse($strategy->matches()); + } + + public function testMatchesReturnsFalseForKmcpPrefixedToken(): void + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer kmcp_live_abc123.def456.ghi789'; + $strategy = new JwtSessionStrategy(); + $this->assertFalse($strategy->matches(), + 'JWT strategy must not claim MCP tokens — that is McpTokenStrategy territory'); + } + + public function testMatchesReturnsFalseForNonBearerScheme(): void + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Basic dXNlcjpwYXNz'; + $strategy = new JwtSessionStrategy(); + $this->assertFalse($strategy->matches()); + } + + public function testMatchesReturnsFalseForOpaqueNonJwtBearer(): void + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer arbitrary-opaque-string-not-a-jwt'; + $strategy = new JwtSessionStrategy(); + $this->assertFalse($strategy->matches()); + } + + public function testMatchesReturnsTrueForValidLookingJwt(): void + { + $jwt = $this->mintToken(); + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $jwt; + $strategy = new JwtSessionStrategy(); + $this->assertTrue($strategy->matches()); + } + + public function testPreAuthPopulatesApiStateForValidJwt(): void + { + $jwt = $this->mintToken(); + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $jwt; + + $strategy = new JwtSessionStrategy(); + $strategy->preAuth($this->api); + + $this->assertNotNull($this->api->account); + $this->assertSame($this->accountId, (int)$this->api->account->id); + $this->assertNotNull($this->api->user); + $this->assertSame($this->userId, (int)$this->api->user->id); + $this->assertSame('jwt', $this->api->response['session']); + } + + /** + * Regression: every JWT-bearer request to a protected MVC endpoint + * was returning 403 "Unauthorized API request." because + * ModelController::authenticate() gates on + * `$this->api->session->hasSession`, and JwtSessionStrategy.preAuth + * was not setting it. HmacSessionStrategy gets hasSession=true + * indirectly via $api->session->validate(); JWT has no equivalent + * cookie-validation step, so it must set it explicitly. + */ + public function testPreAuthMarksSessionAsAuthenticatedForProtectedEndpoints(): void + { + // Instantiate a SessionManager the way Api::route() does so we + // can assert against it the same way ModelController::authenticate() + // would. + $this->api->session = new \Kyte\Session\SessionManager( + Session, KyteUser, 'email', 'password', null, false, 3600 + ); + $this->assertFalse($this->api->session->hasSession, 'precondition: SessionManager starts unauthenticated'); + + $jwt = $this->mintToken(); + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $jwt; + + $strategy = new JwtSessionStrategy(); + $strategy->preAuth($this->api); + + $this->assertTrue( + $this->api->session->hasSession, + 'JwtSessionStrategy must set $api->session->hasSession so ModelController::authenticate() accepts the request' + ); + } + + public function testPreAuthRejectsTamperedSignature(): void + { + $jwt = $this->mintToken(); + // Append a character to the signature segment — guaranteed to change + // the decoded bytes and fail HS256 verification regardless of which + // chars the signature happens to contain (strtr-based tamper was + // flaky when the random signature didn't contain the swap char). + $parts = explode('.', $jwt); + $parts[2] = $parts[2] . 'x'; + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . implode('.', $parts); + + $strategy = new JwtSessionStrategy(); + $this->expectException(SessionException::class); + $strategy->preAuth($this->api); + } + + public function testPreAuthRejectsExpiredToken(): void + { + $jwt = $this->mintToken(['exp' => time() - 60]); + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $jwt; + + $strategy = new JwtSessionStrategy(); + $this->expectException(SessionException::class); + $strategy->preAuth($this->api); + } + + public function testPreAuthRejectsWrongIssuer(): void + { + $jwt = $this->mintToken(['iss' => 'not-kyte']); + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $jwt; + + $strategy = new JwtSessionStrategy(); + $this->expectException(SessionException::class); + $strategy->preAuth($this->api); + } + + public function testPreAuthRejectsUnknownAccount(): void + { + $jwt = $this->mintToken(['aud' => 999999999]); + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $jwt; + + $strategy = new JwtSessionStrategy(); + $this->expectException(SessionException::class); + $strategy->preAuth($this->api); + } + + public function testPreAuthRejectsUnknownUser(): void + { + $jwt = $this->mintToken(['sub' => 999999999]); + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $jwt; + + $strategy = new JwtSessionStrategy(); + $this->expectException(SessionException::class); + $strategy->preAuth($this->api); + } + + public function testPreAuthRejectsUserBelongingToDifferentAccount(): void + { + // Create a second account and a user under it. + $otherAcct = new \Kyte\Core\ModelObject(KyteAccount); + $otherAcct->create(['number' => 'jwt-strat-test-other', 'name' => 'Other']); + $otherAccountId = (int)$otherAcct->id; + + // Mint a token claiming our user but a foreign account. + $jwt = $this->mintToken(['aud' => $otherAccountId]); + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $jwt; + + $strategy = new JwtSessionStrategy(); + $this->expectException(SessionException::class); + try { + $strategy->preAuth($this->api); + } finally { + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number = 'jwt-strat-test-other'"); + } + } + + public function testNameReturnsExpectedLabel(): void + { + $this->assertSame('jwt_session', (new JwtSessionStrategy())->name()); + } + + public function testVerifyIsNoOp(): void + { + // preAuth does the full verification; verify is a no-op in this strategy. + $strategy = new JwtSessionStrategy(); + $strategy->verify($this->api); + $this->assertTrue(true); + } + + private function mintToken(array $overrides = []): string + { + $now = time(); + $payload = array_merge([ + 'iss' => self::ISSUER, + 'sub' => $this->userId, + 'aud' => $this->accountId, + 'exp' => $now + 900, + 'nbf' => $now, + 'iat' => $now, + 'jti' => bin2hex(random_bytes(8)), + 'email' => 'jwt-strat-test@example.com', + ], $overrides); + return JWT::encode($payload, KYTE_JWT_SECRET, 'HS256'); + } +} diff --git a/tests/McpControllerToolsTest.php b/tests/McpControllerToolsTest.php new file mode 100644 index 00000000..0b66d127 --- /dev/null +++ b/tests/McpControllerToolsTest.php @@ -0,0 +1,259 @@ +api = new Api(); + + \Kyte\Core\DBI::createTable(KyteAccount); + \Kyte\Core\DBI::createTable(Application); + \Kyte\Core\DBI::createTable(Controller); + \Kyte\Core\DBI::createTable(constant('Function')); + \Kyte\Core\DBI::createTable(KyteFunctionVersion); + \Kyte\Core\DBI::createTable(KyteFunctionVersionContent); + + // Wipe any prior test state for both accounts so reruns are stable. + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number IN ('" . self::OWN_ACCOUNT . "','" . self::OTHER_ACCOUNT . "')"); + \Kyte\Core\DBI::query("DELETE FROM `Application` WHERE identifier LIKE 'mcp-tools-test-%'"); + \Kyte\Core\DBI::query("DELETE FROM `Controller` WHERE name LIKE 'McpToolsTest%'"); + \Kyte\Core\DBI::query("DELETE FROM `Function` WHERE name LIKE 'mcptt_%'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteFunctionVersion` WHERE content_hash LIKE 'mcptt-%'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteFunctionVersionContent` WHERE content_hash LIKE 'mcptt-%'"); + + // Two accounts. Token-under-test belongs to OWN; OTHER exists only as + // foreign data the tool must refuse to return. + $this->ownAccountId = $this->createAccount(self::OWN_ACCOUNT, 'Own Account'); + $this->otherAccountId = $this->createAccount(self::OTHER_ACCOUNT, 'Other Account'); + + $this->ownAppId = $this->createApp('mcp-tools-test-own-app', $this->ownAccountId); + $this->otherAppId = $this->createApp('mcp-tools-test-other-app', $this->otherAccountId); + + $this->ownControllerId = $this->createController( + 'McpToolsTestOwnController', + $this->ownAppId, + $this->ownAccountId, + "otherControllerId = $this->createController( + 'McpToolsTestOtherController', + $this->otherAppId, + $this->otherAccountId, + "ownFunctionId = $this->createFunction( + 'mcptt_own_hook_init', + $this->ownControllerId, + $this->ownAccountId, + 'hook_init', + "// live source for own function v2\n" + ); + $this->otherFunctionId = $this->createFunction( + 'mcptt_other_hook_init', + $this->otherControllerId, + $this->otherAccountId, + 'hook_init', + "// other account, must not be visible\n" + ); + + // Seed one historical version on the own function so read_function's + // versioned branch has something to find. + $this->createFunctionVersion( + $this->ownFunctionId, + $this->ownAccountId, + 1, + 'mcptt-own-v1', + "// historical own function v1\n" + ); + + $this->api->account = new \Kyte\Core\ModelObject(KyteAccount); + $this->api->account->retrieve('id', $this->ownAccountId); + $this->api->mcpScopes = ['read']; + + $this->tools = new ControllerTools($this->api); + + $_SERVER = ['REMOTE_ADDR' => '127.0.0.1']; + } + + public function testListControllersReturnsControllersForOwnApp(): void + { + $result = $this->tools->listControllers($this->ownAppId); + $rows = $result['controllers']; + + $this->assertCount(1, $rows); + $this->assertSame($this->ownControllerId, $rows[0]['id']); + $this->assertSame('McpToolsTestOwnController', $rows[0]['name']); + $this->assertFalse($rows[0]['kyte_locked']); + } + + public function testListControllersRejectsForeignApplicationId(): void + { + $result = $this->tools->listControllers($this->otherAppId); + $this->assertSame(['controllers' => []], $result, 'list_controllers must not return controllers for an application owned by another account'); + } + + public function testReadControllerReturnsFullRecordWithCode(): void + { + $row = $this->tools->readController($this->ownControllerId); + + $this->assertNotNull($row); + $this->assertSame($this->ownControllerId, $row['id']); + $this->assertSame('McpToolsTestOwnController', $row['name']); + $this->assertStringContainsString('own controller code', $row['code']); + $this->assertSame($this->ownAppId, $row['application']); + } + + public function testReadControllerRejectsForeignControllerId(): void + { + $row = $this->tools->readController($this->otherControllerId); + $this->assertNull($row, 'read_controller must not return a controller belonging to another account'); + } + + public function testListFunctionsReturnsFunctionsForOwnController(): void + { + $result = $this->tools->listFunctions($this->ownControllerId); + $rows = $result['functions']; + + $this->assertCount(1, $rows); + $this->assertSame($this->ownFunctionId, $rows[0]['id']); + $this->assertSame('hook_init', $rows[0]['type']); + } + + public function testListFunctionsRejectsForeignControllerId(): void + { + $result = $this->tools->listFunctions($this->otherControllerId); + $this->assertSame(['functions' => []], $result); + } + + public function testReadFunctionWithoutVersionReturnsLiveSource(): void + { + $row = $this->tools->readFunction($this->ownFunctionId); + + $this->assertNotNull($row); + $this->assertStringContainsString('live source for own function v2', $row['code']); + $this->assertNull($row['version']); + } + + public function testReadFunctionWithVersionReturnsHistoricalSnapshot(): void + { + $row = $this->tools->readFunction($this->ownFunctionId, 1); + + $this->assertNotNull($row); + $this->assertSame(1, $row['version']); + $this->assertStringContainsString('historical own function v1', $row['code']); + } + + public function testReadFunctionWithUnknownVersionReturnsNull(): void + { + // Version 999 was never created. + $row = $this->tools->readFunction($this->ownFunctionId, 999); + $this->assertNull($row); + } + + public function testReadFunctionRejectsForeignFunctionId(): void + { + $row = $this->tools->readFunction($this->otherFunctionId); + $this->assertNull($row); + } + + private function createAccount(string $number, string $name): int + { + $obj = new \Kyte\Core\ModelObject(KyteAccount); + $obj->create(['number' => $number, 'name' => $name]); + return (int)$obj->id; + } + + private function createApp(string $identifier, int $accountId): int + { + $obj = new \Kyte\Core\ModelObject(Application); + $obj->create([ + 'name' => 'App for ' . $identifier, + 'identifier' => $identifier, + 'kyte_account' => $accountId, + ]); + return (int)$obj->id; + } + + private function createController(string $name, int $appId, int $accountId, string $code): int + { + $obj = new \Kyte\Core\ModelObject(Controller); + $obj->create([ + 'name' => $name, + 'application' => $appId, + 'code' => $code, + 'kyte_account' => $accountId, + ]); + return (int)$obj->id; + } + + private function createFunction(string $name, int $controllerId, int $accountId, string $type, string $code): int + { + $obj = new \Kyte\Core\ModelObject(constant('Function')); + $obj->create([ + 'name' => $name, + 'controller' => $controllerId, + 'type' => $type, + 'code' => $code, + 'kyte_account' => $accountId, + ]); + return (int)$obj->id; + } + + private function createFunctionVersion(int $functionId, int $accountId, int $versionNumber, string $contentHash, string $code): void + { + $content = new \Kyte\Core\ModelObject(KyteFunctionVersionContent); + $content->create([ + 'content_hash' => $contentHash, + 'code' => $code, + 'reference_count' => 1, + 'last_referenced' => time(), + 'kyte_account' => $accountId, + ]); + + $version = new \Kyte\Core\ModelObject(KyteFunctionVersion); + $version->create([ + 'function' => $functionId, + 'version_number' => $versionNumber, + 'version_type' => 'manual_save', + 'content_hash' => $contentHash, + 'kyte_account' => $accountId, + ]); + } +} diff --git a/tests/McpEndpointTest.php b/tests/McpEndpointTest.php new file mode 100644 index 00000000..91a380f0 --- /dev/null +++ b/tests/McpEndpointTest.php @@ -0,0 +1,278 @@ +api = new \Kyte\Core\Api(); + $this->psr17 = new Psr17Factory(); + + \Kyte\Core\DBI::createTable(KyteAccount); + \Kyte\Core\DBI::createTable(KyteMCPToken); + \Kyte\Core\DBI::createTable(Application); + + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number = '" . self::FIXED_ACCOUNT . "'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteMCPToken` WHERE token_prefix LIKE 'kmcp_live_%'"); + \Kyte\Core\DBI::query("DELETE FROM `Application` WHERE identifier = '" . self::APP_IDENT . "'"); + + $account = new \Kyte\Core\ModelObject(KyteAccount); + $account->create([ + 'name' => 'MCP Endpoint Test Account', + 'number' => self::FIXED_ACCOUNT, + ]); + $this->accountId = $account->id; + + $token = new \Kyte\Core\ModelObject(KyteMCPToken); + $token->create([ + 'token_hash' => hash('sha256', self::RAW_TOKEN), + 'token_prefix' => substr(self::RAW_TOKEN, 0, 16), + 'name' => 'Endpoint test token', + 'scopes' => 'read,draft', + 'expires_at' => time() + 3600, + 'revoked_at' => 0, + 'kyte_account' => $this->accountId, + ]); + + $app = new \Kyte\Core\ModelObject(Application); + $app->create([ + 'name' => 'Endpoint Test App', + 'identifier' => self::APP_IDENT, + 'kyte_account' => $this->accountId, + ]); + + $this->api->key = new \Kyte\Core\ModelObject(KyteAPIKey); + $this->api->account = new \Kyte\Core\ModelObject(KyteAccount); + + $_SERVER = ['REMOTE_ADDR' => '127.0.0.1']; + + // Each test starts with a clean MCP session directory. The SDK's + // session machinery rejects a second initialize for the same client + // when prior session files linger; wiping in setUp avoids + // cross-test interference now that multiple tests do a real + // initialize round-trip (the Origin tests added below). + $sessionDir = sys_get_temp_dir() . '/kyte-mcp-sessions'; + if (is_dir($sessionDir)) { + foreach (glob($sessionDir . '/*') as $f) { + @unlink($f); + } + } + } + + private function rpcRequest(string $authHeader, array $body): ServerRequestInterface + { + $json = json_encode($body); + + // Mirror the header into $_SERVER so McpTokenStrategy's $_SERVER read + // sees the same value as the PSR-7 request. In production, both come + // from the same SAPI; in tests we set them in lockstep. + if ($authHeader !== '') { + $_SERVER['HTTP_AUTHORIZATION'] = $authHeader; + } else { + unset($_SERVER['HTTP_AUTHORIZATION']); + } + + return $this->psr17->createServerRequest('POST', '/mcp') + ->withHeader('Authorization', $authHeader) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Accept', 'application/json, text/event-stream') + ->withBody($this->psr17->createStream($json)); + } + + public function testRequestWithoutOriginPasses(): void + { + // CLI clients (Claude Code, curl) don't send Origin. We must not + // require it — see Endpoint::checkOrigin docblock for the full + // rationale. This is the canonical happy-path; the existing + // initialize tests below also exercise it transitively. + $request = $this->rpcRequest('Bearer ' . self::RAW_TOKEN, [ + 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', + 'params' => ['protocolVersion' => '2025-11-25', 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => 'no-origin', 'version' => '0.0.1']], + ]); + + $response = \Kyte\Mcp\Endpoint::process($this->api, $request); + $this->assertSame(200, $response->getStatusCode()); + } + + public function testRequestWithUnknownOriginRejectedWith403(): void + { + // Default state: MCP_ALLOWED_ORIGINS undefined → empty allowlist → + // every browser Origin gets denied. + $request = $this->rpcRequest('Bearer ' . self::RAW_TOKEN, [ + 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', + 'params' => ['protocolVersion' => '2025-11-25', 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => 'malicious', 'version' => '0.0.1']], + ])->withHeader('Origin', 'https://attacker.example'); + + $response = \Kyte\Mcp\Endpoint::process($this->api, $request); + + $this->assertSame(403, $response->getStatusCode()); + $payload = json_decode((string)$response->getBody(), true); + $this->assertSame(-32011, $payload['error']['code']); + $this->assertStringContainsString('attacker.example', $payload['error']['message']); + } + + public function testRequestWithAllowlistedOriginPasses(): void + { + // Define the constant for this test only. PHP doesn't allow undefining + // constants, so subsequent tests in the same run will see this allowlist + // — but each test that cares either sets a non-overlapping origin or + // sends no Origin at all, so there's no cross-test interference. + if (!defined('MCP_ALLOWED_ORIGINS')) { + define('MCP_ALLOWED_ORIGINS', 'https://claude.ai,https://app.example.com'); + } + + $request = $this->rpcRequest('Bearer ' . self::RAW_TOKEN, [ + 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', + 'params' => ['protocolVersion' => '2025-11-25', 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => 'browser', 'version' => '0.0.1']], + ])->withHeader('Origin', 'https://claude.ai'); + + $response = \Kyte\Mcp\Endpoint::process($this->api, $request); + $this->assertSame(200, $response->getStatusCode()); + } + + public function testInitializeReturnsServerCapabilitiesAndSessionId(): void + { + $request = $this->rpcRequest('Bearer ' . self::RAW_TOKEN, [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'phpunit', 'version' => '0.0.1'], + ], + ]); + + $response = \Kyte\Mcp\Endpoint::process($this->api, $request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNotEmpty($response->getHeaderLine('Mcp-Session-Id')); + + $payload = json_decode((string)$response->getBody(), true); + $this->assertSame('2.0', $payload['jsonrpc']); + $this->assertSame(1, $payload['id']); + $this->assertArrayHasKey('result', $payload); + $this->assertArrayHasKey('tools', $payload['result']['capabilities']); + $this->assertSame('Kyte MCP', $payload['result']['serverInfo']['name']); + } + + public function testInitializeAlsoPopulatesApiAccountFromToken(): void + { + $request = $this->rpcRequest('Bearer ' . self::RAW_TOKEN, [ + 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'phpunit', 'version' => '0.0.1'], + ], + ]); + + \Kyte\Mcp\Endpoint::process($this->api, $request); + + $this->assertSame(self::FIXED_ACCOUNT, $this->api->account->number); + $this->assertNotNull($this->api->mcpToken); + $this->assertSame(['read', 'draft'], $this->api->mcpScopes); + } + + public function testMissingAuthorizationHeaderReturns401WithJsonRpcError(): void + { + $request = $this->rpcRequest('', [ + 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', + 'params' => ['protocolVersion' => '2025-11-25', 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => 'phpunit', 'version' => '0.0.1']], + ]); + + $response = \Kyte\Mcp\Endpoint::process($this->api, $request); + + $this->assertSame(401, $response->getStatusCode()); + $payload = json_decode((string)$response->getBody(), true); + $this->assertSame('2.0', $payload['jsonrpc']); + $this->assertArrayHasKey('error', $payload); + $this->assertSame(-32001, $payload['error']['code']); + $this->assertStringContainsString('Authorization', $payload['error']['message']); + } + + public function testInvalidTokenReturns401(): void + { + $request = $this->rpcRequest('Bearer kmcp_live_does_not_exist_abc123xyz', [ + 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', + 'params' => ['protocolVersion' => '2025-11-25', 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => 'phpunit', 'version' => '0.0.1']], + ]); + + $response = \Kyte\Mcp\Endpoint::process($this->api, $request); + + $this->assertSame(401, $response->getStatusCode()); + $payload = json_decode((string)$response->getBody(), true); + $this->assertStringContainsString('Invalid MCP token', $payload['error']['message']); + } + + public function testNonMcpBearerTokenReturns401(): void + { + // A JWT-shaped bearer should be rejected by /mcp because no other + // strategy claims it for now. + $request = $this->rpcRequest('Bearer eyJhbGciOiJSUzI1NiJ9.payload.sig', [ + 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', + 'params' => ['protocolVersion' => '2025-11-25', 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => 'phpunit', 'version' => '0.0.1']], + ]); + + $response = \Kyte\Mcp\Endpoint::process($this->api, $request); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testListApplicationsReturnsEmptyForUnknownAccount(): void + { + $tools = new \Kyte\Mcp\Tools\AccountTools($this->api); + // $api->account is the empty ModelObject from setUp — id is unset + $this->assertSame(['applications' => []], $tools->listApplications()); + } + + public function testListApplicationsToolReturnsAccountScopedApps(): void + { + // Populate $api->account as McpTokenStrategy::preAuth would in a real + // request, then drive AccountTools directly. Equivalent to the + // tool-dispatch path the SDK takes after initialize → tools/call, but + // skips the multi-step session handshake — that was already verified + // end-to-end during the SDK evaluation on dev. + $this->api->account->retrieve('id', $this->accountId); + + $tools = new \Kyte\Mcp\Tools\AccountTools($this->api); + $result = $tools->listApplications(); + $apps = $result['applications']; + + $this->assertCount(1, $apps); + $this->assertSame(self::APP_IDENT, $apps[0]['identifier']); + $this->assertSame('Endpoint Test App', $apps[0]['name']); + $this->assertIsInt($apps[0]['id']); + } +} diff --git a/tests/McpModelToolsTest.php b/tests/McpModelToolsTest.php new file mode 100644 index 00000000..9df59a22 --- /dev/null +++ b/tests/McpModelToolsTest.php @@ -0,0 +1,159 @@ +api = new Api(); + + \Kyte\Core\DBI::createTable(KyteAccount); + \Kyte\Core\DBI::createTable(Application); + \Kyte\Core\DBI::createTable(DataModel); + + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number IN ('" . self::OWN_ACCOUNT . "','" . self::OTHER_ACCOUNT . "')"); + \Kyte\Core\DBI::query("DELETE FROM `Application` WHERE identifier LIKE 'mcp-model-test-%'"); + \Kyte\Core\DBI::query("DELETE FROM `DataModel` WHERE name LIKE 'McpModelTest%'"); + + $this->ownAccountId = $this->createAccount(self::OWN_ACCOUNT, 'Own'); + $this->otherAccountId = $this->createAccount(self::OTHER_ACCOUNT, 'Other'); + + $this->ownAppId = $this->createApp('mcp-model-test-own', $this->ownAccountId); + $this->otherAppId = $this->createApp('mcp-model-test-other', $this->otherAccountId); + + $wellFormed = json_encode([ + 'name' => 'McpModelTestOwn', + 'struct' => [ + 'name' => ['type' => 's', 'size' => 255, 'required' => true], + 'qty' => ['type' => 'i', 'size' => 11, 'required' => false], + ], + ]); + $this->ownModelId = $this->createDataModel('McpModelTestOwn', $this->ownAppId, $this->ownAccountId, $wellFormed); + + $this->otherModelId = $this->createDataModel( + 'McpModelTestOther', + $this->otherAppId, + $this->otherAccountId, + json_encode(['name' => 'McpModelTestOther', 'struct' => []]) + ); + + // Malformed JSON variant — exercises the read_model defensive path. + $this->ownMalformedModelId = $this->createDataModel( + 'McpModelTestOwnBroken', + $this->ownAppId, + $this->ownAccountId, + '{not valid json' + ); + + $this->api->account = new \Kyte\Core\ModelObject(KyteAccount); + $this->api->account->retrieve('id', $this->ownAccountId); + $this->api->mcpScopes = ['read']; + + $this->tools = new ModelTools($this->api); + + $_SERVER = ['REMOTE_ADDR' => '127.0.0.1']; + } + + public function testListModelsReturnsModelsForOwnApp(): void + { + $result = $this->tools->listModels($this->ownAppId); + $rows = $result['models']; + + // Two own models in this app: one well-formed, one malformed. + $this->assertCount(2, $rows); + $names = array_column($rows, 'name'); + $this->assertContains('McpModelTestOwn', $names); + $this->assertContains('McpModelTestOwnBroken', $names); + } + + public function testListModelsRejectsForeignApplicationId(): void + { + $result = $this->tools->listModels($this->otherAppId); + $this->assertSame(['models' => []], $result); + } + + public function testReadModelDecodesJsonDefinition(): void + { + $row = $this->tools->readModel($this->ownModelId); + + $this->assertNotNull($row); + $this->assertSame('McpModelTestOwn', $row['name']); + $this->assertIsArray($row['definition']); + $this->assertSame('McpModelTestOwn', $row['definition']['name']); + $this->assertArrayHasKey('struct', $row['definition']); + $this->assertSame('s', $row['definition']['struct']['name']['type']); + $this->assertNull($row['raw_definition'], 'raw_definition should be null when JSON parses cleanly'); + } + + public function testReadModelPreservesRawWhenJsonInvalid(): void + { + $row = $this->tools->readModel($this->ownMalformedModelId); + + $this->assertNotNull($row); + $this->assertNull($row['definition']); + $this->assertSame('{not valid json', $row['raw_definition']); + } + + public function testReadModelRejectsForeignModelId(): void + { + $row = $this->tools->readModel($this->otherModelId); + $this->assertNull($row); + } + + private function createAccount(string $number, string $name): int + { + $obj = new \Kyte\Core\ModelObject(KyteAccount); + $obj->create(['number' => $number, 'name' => $name]); + return (int)$obj->id; + } + + private function createApp(string $identifier, int $accountId): int + { + $obj = new \Kyte\Core\ModelObject(Application); + $obj->create([ + 'name' => 'App ' . $identifier, + 'identifier' => $identifier, + 'kyte_account' => $accountId, + ]); + return (int)$obj->id; + } + + private function createDataModel(string $name, int $appId, int $accountId, string $definition): int + { + $obj = new \Kyte\Core\ModelObject(DataModel); + $obj->create([ + 'name' => $name, + 'application' => $appId, + 'model_definition' => $definition, + 'kyte_account' => $accountId, + ]); + return (int)$obj->id; + } +} diff --git a/tests/McpPageToolsTest.php b/tests/McpPageToolsTest.php new file mode 100644 index 00000000..f29b545c --- /dev/null +++ b/tests/McpPageToolsTest.php @@ -0,0 +1,240 @@ +api = new Api(); + + \Kyte\Core\DBI::createTable(KyteAccount); + \Kyte\Core\DBI::createTable(Application); + \Kyte\Core\DBI::createTable(KyteSite); + \Kyte\Core\DBI::createTable(KytePage); + \Kyte\Core\DBI::createTable(KytePageVersion); + \Kyte\Core\DBI::createTable(KytePageVersionContent); + + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number IN ('" . self::OWN_ACCOUNT . "','" . self::OTHER_ACCOUNT . "')"); + \Kyte\Core\DBI::query("DELETE FROM `Application` WHERE identifier LIKE 'mcp-page-test-%'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteSite` WHERE name LIKE 'McpPageTest%'"); + \Kyte\Core\DBI::query("DELETE FROM `KytePage` WHERE title LIKE 'McpPageTest%'"); + \Kyte\Core\DBI::query("DELETE FROM `KytePageVersion` WHERE content_hash LIKE 'mcp-page-test-%'"); + \Kyte\Core\DBI::query("DELETE FROM `KytePageVersionContent` WHERE content_hash LIKE 'mcp-page-test-%'"); + + $this->ownAccountId = $this->createAccount(self::OWN_ACCOUNT, 'Own'); + $this->otherAccountId = $this->createAccount(self::OTHER_ACCOUNT, 'Other'); + + $this->ownAppId = $this->createApp('mcp-page-test-own', $this->ownAccountId); + $this->otherAppId = $this->createApp('mcp-page-test-other', $this->otherAccountId); + + $this->ownSiteId = $this->createSite('McpPageTestOwnSite', $this->ownAppId, $this->ownAccountId); + $this->otherSiteId = $this->createSite('McpPageTestOtherSite', $this->otherAppId, $this->otherAccountId); + + $this->ownPageId = $this->createPage('McpPageTestOwnHome', $this->ownSiteId, $this->ownAccountId); + $this->otherPageId = $this->createPage('McpPageTestOtherHome', $this->otherSiteId, $this->otherAccountId); + + // Page that exists but has no versions yet — exercises the + // metadata-with-empty-content path. + $this->ownPageNoVersionsId = $this->createPage('McpPageTestOwnFresh', $this->ownSiteId, $this->ownAccountId); + + // Two versions on the own page: v1 (older) and v2 (current). + $this->createPageVersion( + $this->ownPageId, + $this->ownAccountId, + 1, + 'mcp-page-test-own-v1', + ['html' => '

v1 own

', 'stylesheet' => 'body{color:red}', 'javascript' => 'console.log("v1");'], + isCurrent: false + ); + $this->createPageVersion( + $this->ownPageId, + $this->ownAccountId, + 2, + 'mcp-page-test-own-v2', + ['html' => '

v2 own

', 'stylesheet' => 'body{color:blue}', 'javascript' => 'console.log("v2");'], + isCurrent: true + ); + + $this->api->account = new \Kyte\Core\ModelObject(KyteAccount); + $this->api->account->retrieve('id', $this->ownAccountId); + $this->api->mcpScopes = ['read']; + + $this->tools = new PageTools($this->api); + + $_SERVER = ['REMOTE_ADDR' => '127.0.0.1']; + } + + public function testListSitesReturnsSitesForOwnApp(): void + { + $result = $this->tools->listSites($this->ownAppId); + $rows = $result['sites']; + $this->assertCount(1, $rows); + $this->assertSame('McpPageTestOwnSite', $rows[0]['name']); + } + + public function testListSitesRejectsForeignApplicationId(): void + { + $this->assertSame(['sites' => []], $this->tools->listSites($this->otherAppId)); + } + + public function testListPagesReturnsPagesOnOwnSite(): void + { + $result = $this->tools->listPages($this->ownSiteId); + $rows = $result['pages']; + $this->assertCount(2, $rows, 'Should include own page and the no-versions page'); + $titles = array_column($rows, 'title'); + $this->assertContains('McpPageTestOwnHome', $titles); + $this->assertContains('McpPageTestOwnFresh', $titles); + } + + public function testListPagesRejectsForeignSiteId(): void + { + $this->assertSame(['pages' => []], $this->tools->listPages($this->otherSiteId), 'Foreign site_id must not return its pages'); + } + + public function testReadPageWithoutVersionReturnsCurrentContent(): void + { + $row = $this->tools->readPage($this->ownPageId); + + $this->assertNotNull($row); + $this->assertSame(2, $row['version'], 'Default read should return is_current=1, which is v2 here'); + $this->assertSame('

v2 own

', $row['html']); + $this->assertSame('body{color:blue}', $row['stylesheet']); + $this->assertSame('console.log("v2");', $row['javascript']); + } + + public function testReadPageWithVersionReturnsHistoricalSnapshot(): void + { + $row = $this->tools->readPage($this->ownPageId, 1); + + $this->assertNotNull($row); + $this->assertSame(1, $row['version']); + $this->assertSame('

v1 own

', $row['html']); + $this->assertSame('body{color:red}', $row['stylesheet']); + } + + public function testReadPageWithUnknownVersionReturnsNull(): void + { + $this->assertNull($this->tools->readPage($this->ownPageId, 999)); + } + + public function testReadPageReturnsEmptyContentWhenNoVersionsExist(): void + { + $row = $this->tools->readPage($this->ownPageNoVersionsId); + + $this->assertNotNull($row, 'Page exists, just has no current version yet — must still be discoverable'); + $this->assertSame('McpPageTestOwnFresh', $row['title']); + $this->assertSame('', $row['html']); + $this->assertSame('', $row['stylesheet']); + $this->assertSame('', $row['javascript']); + $this->assertNull($row['version']); + } + + public function testReadPageRejectsForeignPageId(): void + { + $this->assertNull($this->tools->readPage($this->otherPageId)); + } + + private function createAccount(string $number, string $name): int + { + $obj = new \Kyte\Core\ModelObject(KyteAccount); + $obj->create(['number' => $number, 'name' => $name]); + return (int)$obj->id; + } + + private function createApp(string $identifier, int $accountId): int + { + $obj = new \Kyte\Core\ModelObject(Application); + $obj->create([ + 'name' => 'App ' . $identifier, + 'identifier' => $identifier, + 'kyte_account' => $accountId, + ]); + return (int)$obj->id; + } + + private function createSite(string $name, int $appId, int $accountId): int + { + $obj = new \Kyte\Core\ModelObject(KyteSite); + $obj->create([ + 'name' => $name, + 'status' => 'active', + 'application' => $appId, + 'kyte_account' => $accountId, + ]); + return (int)$obj->id; + } + + private function createPage(string $title, int $siteId, int $accountId): int + { + $obj = new \Kyte\Core\ModelObject(KytePage); + $obj->create([ + 'title' => $title, + 's3key' => strtolower(str_replace(' ', '-', $title)) . '.html', + 'site' => $siteId, + 'kyte_account' => $accountId, + ]); + return (int)$obj->id; + } + + private function createPageVersion(int $pageId, int $accountId, int $versionNumber, string $contentHash, array $content, bool $isCurrent): void + { + $contentRow = new \Kyte\Core\ModelObject(KytePageVersionContent); + $contentRow->create([ + 'content_hash' => $contentHash, + 'html' => $content['html'] ?? '', + 'stylesheet' => $content['stylesheet'] ?? '', + 'javascript' => $content['javascript'] ?? '', + 'reference_count' => 1, + 'last_referenced' => time(), + 'kyte_account' => $accountId, + ]); + + $version = new \Kyte\Core\ModelObject(KytePageVersion); + $version->create([ + 'page' => $pageId, + 'version_number' => $versionNumber, + 'version_type' => 'manual_save', + 'content_hash' => $contentHash, + 'is_current' => $isCurrent ? 1 : 0, + 'kyte_account' => $accountId, + ]); + } +} diff --git a/tests/McpScopeTest.php b/tests/McpScopeTest.php new file mode 100644 index 00000000..ecaa9f5e --- /dev/null +++ b/tests/McpScopeTest.php @@ -0,0 +1,235 @@ +api = new Api(); + + \Kyte\Core\DBI::createTable(KyteAccount); + \Kyte\Core\DBI::createTable(KyteMCPToken); + \Kyte\Core\DBI::createTable(Application); + \Kyte\Core\DBI::createTable(KyteActivityLog); + + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number = '" . self::FIXED_ACCOUNT . "'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteMCPToken` WHERE token_prefix LIKE 'kmcp_live_scope%'"); + \Kyte\Core\DBI::query("DELETE FROM `Application` WHERE identifier = '" . self::APP_IDENT . "'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteActivityLog` WHERE action = 'MCP_SCOPE_VIOLATION'"); + + $account = new \Kyte\Core\ModelObject(KyteAccount); + $account->create([ + 'name' => 'MCP Scope Test Account', + 'number' => self::FIXED_ACCOUNT, + ]); + $this->accountId = (int)$account->id; + + $token = new \Kyte\Core\ModelObject(KyteMCPToken); + $token->create([ + 'token_hash' => hash('sha256', self::RAW_TOKEN), + 'token_prefix' => substr(self::RAW_TOKEN, 0, 16), + 'name' => 'Scope test token', + 'scopes' => 'read,draft', + 'expires_at' => time() + 3600, + 'revoked_at' => 0, + 'kyte_account' => $this->accountId, + ]); + $this->tokenId = (int)$token->id; + + $app = new \Kyte\Core\ModelObject(Application); + $app->create([ + 'name' => 'Scope Test App', + 'identifier' => self::APP_IDENT, + 'kyte_account' => $this->accountId, + ]); + + $this->api->account = new \Kyte\Core\ModelObject(KyteAccount); + $this->api->account->retrieve('id', $this->accountId); + $this->api->mcpToken = $token; + $this->api->mcpScopes = ['read', 'draft']; + + // Build the same registry/handler stack that Endpoint::process builds + // in production. setDiscovery is replaced with manual registration so + // tests don't depend on filesystem discovery state. + $container = new McpContainer(); + $container->set(Api::class, $this->api); + + $this->registry = new McpRegistry(); + $emptySchema = ['type' => 'object', 'properties' => new \stdClass()]; + $this->registry->registerTool( + new \Mcp\Schema\Tool('list_applications', $emptySchema, 'List apps', null), + [\Kyte\Mcp\Tools\AccountTools::class, 'listApplications'], + ); + $this->registry->registerTool( + new \Mcp\Schema\Tool('untagged_tool', $emptySchema, 'Tool with no RequiresScope', null), + [self::class, 'fakeUntaggedTool'], + ); + + $referenceHandler = new ReferenceHandler($container); + $inner = new CallToolHandler($this->registry, $referenceHandler); + $this->scopedHandler = new ScopedCallToolHandler($inner, new ScopeRegistry($this->registry), $this->api); + + $store = new InMemorySessionStore(3600); + $this->session = (new SessionFactory())->create($store); + + $_SERVER = ['REMOTE_ADDR' => '127.0.0.1']; + } + + /** Stand-in for a tool that someone forgot to annotate. */ + public function fakeUntaggedTool(): array + { + return ['ok' => true]; + } + + public function testReadScopedTokenCanCallReadTool(): void + { + $request = new CallToolRequest('list_applications', []); + $this->setRequestId($request, 7); + + $result = $this->scopedHandler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $result, 'Expected a successful Response, got Error: ' . $this->errorMessageOrEmpty($result)); + $this->assertSame(7, $result->getId()); + } + + public function testDraftOnlyTokenIsRejectedFromReadTool(): void + { + $this->api->mcpScopes = ['draft']; // strip read + + $request = new CallToolRequest('list_applications', []); + $this->setRequestId($request, 8); + + $result = $this->scopedHandler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $result); + $this->assertSame(-32010, $result->code); + $this->assertSame(8, $result->id); + $this->assertStringContainsString("requires scope 'read'", $result->message); + + $this->assertViolationLogged('list_applications', 'read'); + } + + public function testToolWithoutRequiresScopeIsRejected(): void + { + $request = new CallToolRequest('untagged_tool', []); + $this->setRequestId($request, 9); + + $result = $this->scopedHandler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $result); + $this->assertSame(-32010, $result->code); + $this->assertStringContainsString('no scope declaration', $result->message); + + $this->assertViolationLogged('untagged_tool', ''); + } + + public function testEmptyScopeSetCannotCallAnyTool(): void + { + $this->api->mcpScopes = []; + + $request = new CallToolRequest('list_applications', []); + $this->setRequestId($request, 10); + + $result = $this->scopedHandler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $result); + $this->assertSame(-32010, $result->code); + $this->assertStringContainsString("requires scope 'read'", $result->message); + } + + public function testScopeRegistryReadsAttributeFromDiscoveredTool(): void + { + // Direct check on ScopeRegistry — proves the reflection lookup matches + // the discovered tool name. If the attribute is moved/removed/renamed + // this test fails before any of the dispatch tests do, surfacing the + // root cause faster. + $registry = new ScopeRegistry($this->registry); + + $this->assertSame('read', $registry->requiredScopeFor('list_applications')); + $this->assertNull($registry->requiredScopeFor('untagged_tool')); + $this->assertNull($registry->requiredScopeFor('does_not_exist')); + } + + /** + * Wrap CallToolRequest with a deterministic id. SDK's Request::withId + * returns a new instance — assign back into the original variable so + * later assertions on $result->getId() see the same value. + */ + private function setRequestId(CallToolRequest &$request, int $id): void + { + $request = $request->withId($id); + } + + private function errorMessageOrEmpty(Response|Error $result): string + { + return $result instanceof Error ? "[{$result->code}] {$result->message}" : ''; + } + + private function assertViolationLogged(string $toolName, string $requiredScope): void + { + // Tool name comes from this test file as a literal — no user input + // means no SQL-injection surface. DBI exposes no quote() helper, so + // direct interpolation is the same pattern the existing tests use. + $rows = \Kyte\Core\DBI::query( + "SELECT * FROM `KyteActivityLog` WHERE action = 'MCP_SCOPE_VIOLATION' AND value = '" + . $toolName . "' ORDER BY id DESC LIMIT 1" + ); + $this->assertNotEmpty($rows, "Expected an MCP_SCOPE_VIOLATION row for tool '{$toolName}'"); + + $row = $rows[0]; + $this->assertSame('KyteMCPToken', $row['model_name']); + $this->assertSame('tool', $row['field']); + $this->assertSame('403', (string)$row['response_code']); + $this->assertSame('denied', $row['response_status']); + + $payload = json_decode($row['request_data'], true); + $this->assertSame($requiredScope, $payload['required_scope']); + $this->assertSame($this->tokenId, (int)$row['record_id']); + } +} diff --git a/tests/McpSensitivityTest.php b/tests/McpSensitivityTest.php new file mode 100644 index 00000000..1ca704a3 --- /dev/null +++ b/tests/McpSensitivityTest.php @@ -0,0 +1,349 @@ +api = new Api(); + + \Kyte\Core\DBI::createTable(KyteAccount); + \Kyte\Core\DBI::createTable(Application); + \Kyte\Core\DBI::createTable(Controller); + \Kyte\Core\DBI::createTable(DataModel); + \Kyte\Core\DBI::createTable(ModelAttribute); + \Kyte\Core\DBI::createTable(constant('Function')); + \Kyte\Core\DBI::createTable(KyteSite); + \Kyte\Core\DBI::createTable(KytePage); + + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number = '" . self::ACCOUNT . "'"); + \Kyte\Core\DBI::query("DELETE FROM `Application` WHERE identifier = 'mcp-sens-test-app'"); + \Kyte\Core\DBI::query("DELETE FROM `Controller` WHERE name LIKE 'McpSensTest%'"); + \Kyte\Core\DBI::query("DELETE FROM `DataModel` WHERE name LIKE 'McpSensTest%'"); + \Kyte\Core\DBI::query("DELETE FROM `ModelAttribute` WHERE name LIKE 'mcp_sens_test_%'"); + \Kyte\Core\DBI::query("DELETE FROM `Function` WHERE name LIKE 'McpSensTestFn%'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteSite` WHERE name LIKE 'McpSensTest%'"); + \Kyte\Core\DBI::query("DELETE FROM `KytePage` WHERE title LIKE 'McpSensTest%'"); + + $acct = new \Kyte\Core\ModelObject(KyteAccount); + $acct->create(['number' => self::ACCOUNT, 'name' => 'MCP Sens Test']); + $this->accountId = (int)$acct->id; + + $app = new \Kyte\Core\ModelObject(Application); + $app->create([ + 'name' => 'McpSensTestApp', + 'identifier' => 'mcp-sens-test-app', + 'kyte_account' => $this->accountId, + ]); + $this->appId = (int)$app->id; + + // Sensitive controller with source code. + $sensCtrl = new \Kyte\Core\ModelObject(Controller); + $sensCtrl->create([ + 'name' => 'McpSensTestSensitiveCtrl', + 'code' => 'public function passThrough() { /* regulated logic */ }', + 'sensitive' => 1, + 'application' => $this->appId, + 'kyte_account' => $this->accountId, + ]); + $this->sensCtrlId = (int)$sensCtrl->id; + + // Plain controller with source code. + $plainCtrl = new \Kyte\Core\ModelObject(Controller); + $plainCtrl->create([ + 'name' => 'McpSensTestPlainCtrl', + 'code' => 'public function helper() { return "ok"; }', + 'sensitive' => 0, + 'application' => $this->appId, + 'kyte_account' => $this->accountId, + ]); + $this->plainCtrlId = (int)$plainCtrl->id; + + // Functions attached to each controller. + $sensFn = new \Kyte\Core\ModelObject(constant('Function')); + $sensFn->create([ + 'name' => 'McpSensTestFnSensitive', + 'type' => 'custom', + 'code' => 'function logic for sensitive ctrl', + 'controller' => $this->sensCtrlId, + 'kyte_account' => $this->accountId, + ]); + $this->sensFnId = (int)$sensFn->id; + + $plainFn = new \Kyte\Core\ModelObject(constant('Function')); + $plainFn->create([ + 'name' => 'McpSensTestFnPlain', + 'type' => 'custom', + 'code' => 'function logic for plain ctrl', + 'controller' => $this->plainCtrlId, + 'kyte_account' => $this->accountId, + ]); + $this->plainFnId = (int)$plainFn->id; + + // Sensitive model. + $sensModel = new \Kyte\Core\ModelObject(DataModel); + $sensModel->create([ + 'name' => 'McpSensTestSensitiveModel', + 'model_definition' => json_encode([ + 'name' => 'McpSensTestSensitiveModel', + 'struct' => [ + 'field_one' => ['type' => 's', 'size' => 255], + ], + ]), + 'application' => $this->appId, + 'sensitive' => 1, + 'kyte_account' => $this->accountId, + ]); + $this->sensModelId = (int)$sensModel->id; + + // Plain model with one sensitive field and one plain field. + $plainModel = new \Kyte\Core\ModelObject(DataModel); + $plainModel->create([ + 'name' => 'McpSensTestPlainModelWithFields', + 'model_definition' => json_encode([ + 'name' => 'McpSensTestPlainModelWithFields', + 'struct' => [ + 'mcp_sens_test_secret' => ['type' => 's', 'size' => 255], + 'mcp_sens_test_locale' => ['type' => 's', 'size' => 32], + ], + ]), + 'application' => $this->appId, + 'sensitive' => 0, + 'kyte_account' => $this->accountId, + ]); + $this->plainModelWithFieldsId = (int)$plainModel->id; + + // Attribute rows that mark mcp_sens_test_secret as field-sensitive. + $attr1 = new \Kyte\Core\ModelObject(ModelAttribute); + $attr1->create([ + 'name' => 'mcp_sens_test_secret', + 'type' => 's', + 'dataModel' => $this->plainModelWithFieldsId, + 'sensitive' => 1, + 'kyte_account' => $this->accountId, + ]); + $attr2 = new \Kyte\Core\ModelObject(ModelAttribute); + $attr2->create([ + 'name' => 'mcp_sens_test_locale', + 'type' => 's', + 'dataModel' => $this->plainModelWithFieldsId, + 'sensitive' => 0, + 'kyte_account' => $this->accountId, + ]); + + // Site + sensitive / plain page setup. + $site = new \Kyte\Core\ModelObject(KyteSite); + $site->create([ + 'name' => 'McpSensTestSite', + 'status' => 'active', + 'application' => $this->appId, + 'kyte_account' => $this->accountId, + ]); + $this->siteId = (int)$site->id; + + $sensPage = new \Kyte\Core\ModelObject(KytePage); + $sensPage->create([ + 'title' => 'McpSensTestSensitivePage', + 's3key' => 'sens-page', + 'site' => $this->siteId, + 'sensitive' => 1, + 'kyte_account' => $this->accountId, + ]); + $this->sensPageId = (int)$sensPage->id; + + $plainPage = new \Kyte\Core\ModelObject(KytePage); + $plainPage->create([ + 'title' => 'McpSensTestPlainPage', + 's3key' => 'plain-page', + 'site' => $this->siteId, + 'sensitive' => 0, + 'kyte_account' => $this->accountId, + ]); + $this->plainPageId = (int)$plainPage->id; + + SensitivityPolicy::resetForTests(); + + $this->api->account = new \Kyte\Core\ModelObject(KyteAccount); + $this->api->account->retrieve('id', $this->accountId); + $this->api->mcpScopes = ['read']; + + $this->controllerTools = new ControllerTools($this->api); + $this->modelTools = new ModelTools($this->api); + $this->pageTools = new PageTools($this->api); + + $_SERVER = ['REMOTE_ADDR' => '127.0.0.1']; + } + + public function testListControllersIncludesSensitiveFlag(): void + { + $result = $this->controllerTools->listControllers($this->appId); + $rows = $result['controllers']; + + $byName = []; + foreach ($rows as $r) { + $byName[$r['name']] = $r; + } + $this->assertArrayHasKey('McpSensTestSensitiveCtrl', $byName); + $this->assertArrayHasKey('McpSensTestPlainCtrl', $byName); + $this->assertTrue($byName['McpSensTestSensitiveCtrl']['sensitive']); + $this->assertFalse($byName['McpSensTestPlainCtrl']['sensitive']); + } + + public function testReadControllerWithholdsCodeWhenSensitive(): void + { + $row = $this->controllerTools->readController($this->sensCtrlId); + $this->assertNotNull($row); + $this->assertTrue($row['sensitive']); + $this->assertNull($row['code'], 'sensitive controller → code withheld'); + $this->assertSame('McpSensTestSensitiveCtrl', $row['name']); + $this->assertSame($this->appId, $row['application'], 'metadata preserved'); + } + + public function testReadControllerReturnsCodeWhenNotSensitive(): void + { + $row = $this->controllerTools->readController($this->plainCtrlId); + $this->assertNotNull($row); + $this->assertFalse($row['sensitive']); + $this->assertSame('public function helper() { return "ok"; }', $row['code']); + } + + public function testReadFunctionWithholdsCodeWhenParentControllerSensitive(): void + { + $row = $this->controllerTools->readFunction($this->sensFnId); + $this->assertNotNull($row); + $this->assertTrue($row['sensitive']); + $this->assertNull($row['code']); + $this->assertSame('McpSensTestFnSensitive', $row['name']); + } + + public function testReadFunctionReturnsCodeWhenParentControllerNotSensitive(): void + { + $row = $this->controllerTools->readFunction($this->plainFnId); + $this->assertNotNull($row); + $this->assertFalse($row['sensitive']); + $this->assertSame('function logic for plain ctrl', $row['code']); + } + + public function testListModelsIncludesSensitiveFlag(): void + { + $result = $this->modelTools->listModels($this->appId); + $rows = $result['models']; + + $byName = []; + foreach ($rows as $r) { + $byName[$r['name']] = $r; + } + $this->assertArrayHasKey('McpSensTestSensitiveModel', $byName); + $this->assertArrayHasKey('McpSensTestPlainModelWithFields', $byName); + $this->assertTrue($byName['McpSensTestSensitiveModel']['sensitive']); + $this->assertFalse($byName['McpSensTestPlainModelWithFields']['sensitive']); + } + + public function testReadModelWithholdsDefinitionWhenSensitive(): void + { + $row = $this->modelTools->readModel($this->sensModelId); + $this->assertNotNull($row); + $this->assertTrue($row['sensitive']); + $this->assertNull($row['definition']); + $this->assertNull($row['raw_definition']); + $this->assertSame('McpSensTestSensitiveModel', $row['name']); + } + + public function testReadModelStripsSensitiveFieldsFromStruct(): void + { + $row = $this->modelTools->readModel($this->plainModelWithFieldsId); + $this->assertNotNull($row); + $this->assertFalse($row['sensitive']); + $this->assertContains('mcp_sens_test_secret', $row['sensitive_fields']); + $this->assertNotContains('mcp_sens_test_locale', $row['sensitive_fields']); + + $struct = $row['definition']['struct'] ?? []; + $this->assertArrayNotHasKey('mcp_sens_test_secret', $struct, + 'field-sensitive attribute stripped from returned struct'); + $this->assertArrayHasKey('mcp_sens_test_locale', $struct, + 'non-sensitive field still present'); + } + + public function testListPagesIncludesSensitiveFlag(): void + { + $result = $this->pageTools->listPages($this->siteId); + $rows = $result['pages']; + + $byTitle = []; + foreach ($rows as $r) { + $byTitle[$r['title']] = $r; + } + $this->assertArrayHasKey('McpSensTestSensitivePage', $byTitle); + $this->assertArrayHasKey('McpSensTestPlainPage', $byTitle); + $this->assertTrue($byTitle['McpSensTestSensitivePage']['sensitive']); + $this->assertFalse($byTitle['McpSensTestPlainPage']['sensitive']); + } + + public function testReadPageWithholdsContentWhenSensitive(): void + { + $row = $this->pageTools->readPage($this->sensPageId); + $this->assertNotNull($row); + $this->assertTrue($row['sensitive']); + $this->assertNull($row['html']); + $this->assertNull($row['stylesheet']); + $this->assertNull($row['javascript']); + $this->assertSame('McpSensTestSensitivePage', $row['title']); + } + + public function testReadPageReturnsMetadataWhenNotSensitive(): void + { + // The plain page has no version content, so html/stylesheet/javascript + // come back as empty strings (existing behavior), NOT null. This + // distinguishes "exists but no content yet" (empty strings) from + // "exists but gated" (nulls). + $row = $this->pageTools->readPage($this->plainPageId); + $this->assertNotNull($row); + $this->assertFalse($row['sensitive']); + $this->assertSame('', $row['html']); + $this->assertSame('', $row['stylesheet']); + $this->assertSame('', $row['javascript']); + } +} diff --git a/tests/McpTokenControllerTest.php b/tests/McpTokenControllerTest.php new file mode 100644 index 00000000..a021df7e --- /dev/null +++ b/tests/McpTokenControllerTest.php @@ -0,0 +1,240 @@ +account directly + } +} + +/** + * Tests for KyteMCPToken issuance and revocation flow. + * + * Drives the controller's new() / delete() methods directly with a + * forged Api context. Each test verifies one of: + * - successful issuance returns the raw token in the response and + * persists only the hash + prefix + * - the kyte_account override is honored even when the request + * tries to overwrite it (privilege-escalation guard) + * - scope validation rejects unknown scope strings + * - default expires_at falls into a sane window when the request + * omits it + * - revoke logs MCP_TOKEN_REVOKE with enough context to identify + * what was revoked (prefix + scopes + last_used metadata) + * - token_hash never appears in response payloads (protected:true on + * the model field) + */ +class McpTokenControllerTest extends TestCase +{ + private const OWN_ACCOUNT = 'mcp-issuance-test-own'; + private const FOREIGN_ACCOUNT = 'mcp-issuance-test-foreign'; + + /** @var \Kyte\Core\Api */ + private $api; + private int $ownAccountId; + private int $foreignAccountId; + private array $response; + + protected function setUp(): void + { + \Kyte\Core\DBI::dbInit(KYTE_DB_USERNAME, KYTE_DB_PASSWORD, KYTE_DB_HOST, KYTE_DB_DATABASE, KYTE_DB_CHARSET, 'InnoDB'); + + $this->api = new \Kyte\Core\Api(); + + \Kyte\Core\DBI::createTable(KyteAccount); + \Kyte\Core\DBI::createTable(KyteMCPToken); + \Kyte\Core\DBI::createTable(KyteActivityLog); + + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number IN ('" . self::OWN_ACCOUNT . "','" . self::FOREIGN_ACCOUNT . "')"); + \Kyte\Core\DBI::query("DELETE FROM `KyteMCPToken` WHERE name LIKE 'McpIssuanceTest%'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteActivityLog` WHERE action IN ('MCP_TOKEN_ISSUE','MCP_TOKEN_REVOKE')"); + + $this->ownAccountId = $this->createAccount(self::OWN_ACCOUNT, 'Own'); + $this->foreignAccountId = $this->createAccount(self::FOREIGN_ACCOUNT, 'Foreign'); + + // Fake auth context: bind the controller to OWN account. + $this->api->account = new \Kyte\Core\ModelObject(KyteAccount); + $this->api->account->retrieve('id', $this->ownAccountId); + + // Minimum app/user/session shape ModelController constructor reads. + // The controller's authenticate() is no-op'd in the subclass, so + // these are only here so the framework's null-safe accesses don't + // trip during init(). + $this->api->user = new \Kyte\Core\ModelObject(KyteAPIKey); + $this->api->session = new \stdClass(); + $this->api->session->hasSession = true; + $this->api->app = new \stdClass(); + $this->api->app->org_model = null; + $this->api->app->userorg_colname = null; + + $this->response = []; + $_SERVER = ['REMOTE_ADDR' => '127.0.0.1']; + } + + private function makeController(): TestableKyteMCPTokenController + { + return new TestableKyteMCPTokenController(KyteMCPToken, $this->api, 'U', $this->response); + } + + public function testIssuanceReturnsRawTokenAndPersistsHash(): void + { + $controller = $this->makeController(); + $controller->new([ + 'name' => 'McpIssuanceTestLaptop', + 'scopes' => 'read,draft', + ]); + + $this->assertArrayHasKey('data', $this->response); + $this->assertCount(1, $this->response['data']); + $created = $this->response['data'][0]; + + $this->assertArrayHasKey('raw_token', $created, 'response must include the raw token exactly once'); + $this->assertStringStartsWith('kmcp_live_', $created['raw_token']); + $this->assertSame(42, strlen($created['raw_token']), 'kmcp_live_ + 32 body chars'); + + // Hash is server-side; never echoed back. The framework keeps the + // field key present but blanks the value when `protected:true` is + // set on the model — that's the right shape for downstream API + // consumers (the field's existence is documented; only the value + // is hidden). Match that shape in the assertion. + $this->assertSame('', $created['token_hash'], 'token_hash value must be blanked (protected:true)'); + + // Prefix in response = first 16 chars of raw token. + $this->assertSame(substr($created['raw_token'], 0, 16), $created['token_prefix']); + + // Persisted row carries the hash; raw is never stored. + $row = new \Kyte\Core\ModelObject(KyteMCPToken); + $row->retrieve('id', $created['id']); + $this->assertSame(hash('sha256', $created['raw_token']), $row->token_hash); + $this->assertSame($this->ownAccountId, (int)$row->kyte_account); + $this->assertSame('read,draft', $row->scopes); + } + + public function testIssuanceForcesAccountOverrideOnRequest(): void + { + $controller = $this->makeController(); + + // Malicious request: try to mint a token bound to a foreign account. + $controller->new([ + 'name' => 'McpIssuanceTestEvil', + 'scopes' => 'commit', + 'kyte_account' => $this->foreignAccountId, + ]); + + $this->assertArrayHasKey('data', $this->response); + $this->assertCount(1, $this->response['data']); + $created = $this->response['data'][0]; + + $row = new \Kyte\Core\ModelObject(KyteMCPToken); + $row->retrieve('id', $created['id']); + $this->assertSame($this->ownAccountId, (int)$row->kyte_account, 'kyte_account override must win regardless of request body'); + $this->assertNotSame($this->foreignAccountId, (int)$row->kyte_account); + } + + public function testIssuanceRejectsInvalidScope(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/Invalid scope/'); + + $controller = $this->makeController(); + $controller->new([ + 'name' => 'McpIssuanceTestBadScope', + 'scopes' => 'read,admin', // 'admin' isn't valid + ]); + } + + public function testIssuanceRejectsMissingScopes(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/scopes is required/'); + + $controller = $this->makeController(); + $controller->new(['name' => 'McpIssuanceTestNoScope']); + } + + public function testIssuanceDefaultsExpiresAtTo30Days(): void + { + $before = time(); + $controller = $this->makeController(); + $controller->new([ + 'name' => 'McpIssuanceTestDefaultTtl', + 'scopes' => 'read', + ]); + + $row = new \Kyte\Core\ModelObject(KyteMCPToken); + $row->retrieve('id', $this->response['data'][0]['id']); + + $expected = $before + 30 * 86400; + // Allow a few seconds of clock drift between $before and the row insert. + $this->assertGreaterThanOrEqual($expected, (int)$row->expires_at); + $this->assertLessThan($expected + 60, (int)$row->expires_at); + } + + public function testIssuanceWritesMcpTokenIssueAuditRow(): void + { + $controller = $this->makeController(); + $controller->new([ + 'name' => 'McpIssuanceTestAudit', + 'scopes' => 'read', + ]); + + $rows = \Kyte\Core\DBI::query( + "SELECT * FROM `KyteActivityLog` WHERE action = 'MCP_TOKEN_ISSUE' ORDER BY id DESC LIMIT 1" + ); + $this->assertNotEmpty($rows); + $row = $rows[0]; + + $this->assertSame('KyteMCPToken', $row['model_name']); + $this->assertSame('token_prefix', $row['field']); + $this->assertSame((int)$this->response['data'][0]['id'], (int)$row['record_id']); + $this->assertSame('issued', $row['response_status']); + + $payload = json_decode($row['request_data'], true); + $this->assertSame('read', $payload['scopes']); + $this->assertGreaterThan(time(), (int)$payload['expires_at']); + } + + public function testRevokeWritesMcpTokenRevokeAuditRow(): void + { + // First issue a token, then delete it through the same controller. + $controller = $this->makeController(); + $controller->new([ + 'name' => 'McpIssuanceTestRevokeMe', + 'scopes' => 'read', + ]); + $createdId = $this->response['data'][0]['id']; + $createdPrefix = $this->response['data'][0]['token_prefix']; + + // delete() expects (field, value). + $controller->delete('id', $createdId); + + $rows = \Kyte\Core\DBI::query( + "SELECT * FROM `KyteActivityLog` WHERE action = 'MCP_TOKEN_REVOKE' ORDER BY id DESC LIMIT 1" + ); + $this->assertNotEmpty($rows); + $row = $rows[0]; + + $this->assertSame('revoked', $row['response_status']); + $this->assertSame($createdPrefix, $row['value']); + $this->assertSame((int)$createdId, (int)$row['record_id']); + } + + private function createAccount(string $number, string $name): int + { + $obj = new \Kyte\Core\ModelObject(KyteAccount); + $obj->create(['number' => $number, 'name' => $name]); + return (int)$obj->id; + } +} diff --git a/tests/McpTokenStrategyTest.php b/tests/McpTokenStrategyTest.php new file mode 100644 index 00000000..bc4bef16 --- /dev/null +++ b/tests/McpTokenStrategyTest.php @@ -0,0 +1,234 @@ +api = new \Kyte\Core\Api(); + $this->strategy = new \Kyte\Core\Auth\McpTokenStrategy(); + + \Kyte\Core\DBI::createTable(KyteAccount); + \Kyte\Core\DBI::createTable(KyteMCPToken); + \Kyte\Core\DBI::createTable(KyteActivityLog); + + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number = '" . self::FIXED_ACCOUNT . "'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteMCPToken` WHERE token_prefix LIKE 'kmcp_live_%'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteActivityLog` WHERE action = 'MCP_TOKEN_USE'"); + + $account = new \Kyte\Core\ModelObject(KyteAccount); + $account->create([ + 'name' => 'MCP Strategy Test Account', + 'number' => self::FIXED_ACCOUNT, + ]); + $this->accountId = $account->id; + + $this->seedToken(self::RAW_TOKEN, ['expires_at' => time() + 3600]); + $this->seedToken(self::REVOKED_RAW, ['revoked_at' => time() - 60, 'expires_at' => time() + 3600]); + $this->seedToken(self::EXPIRED_RAW, ['expires_at' => time() - 60]); + $this->seedToken(self::IP_RESTRICTED_RAW, [ + 'expires_at' => time() + 3600, + 'ip_allowlist' => '203.0.113.0/24, 2001:db8::/32', + ]); + + $this->api->account = new \Kyte\Core\ModelObject(KyteAccount); + + $_SERVER = ['REMOTE_ADDR' => self::CLIENT_IP]; + } + + private function seedToken(string $raw, array $overrides = []): void + { + $token = new \Kyte\Core\ModelObject(KyteMCPToken); + $token->create(array_merge([ + 'token_hash' => hash('sha256', $raw), + 'token_prefix' => substr($raw, 0, 16), + 'name' => 'Test token: ' . substr($raw, 10, 8), + 'scopes' => 'read,draft', + 'expires_at' => 0, + 'revoked_at' => 0, + 'kyte_account' => $this->accountId, + ], $overrides)); + } + + private function setAuthHeader(string $raw): void + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $raw; + } + + public function testMatchesTrueForBearerWithMcpPrefix(): void + { + $this->setAuthHeader(self::RAW_TOKEN); + $this->assertTrue($this->strategy->matches()); + } + + public function testMatchesFalseWhenNoAuthorizationHeader(): void + { + $this->assertFalse($this->strategy->matches()); + } + + public function testMatchesFalseForNonBearerAuthScheme(): void + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Basic ' . base64_encode('user:pass'); + $this->assertFalse($this->strategy->matches()); + } + + public function testMatchesFalseForBearerWithoutMcpPrefix(): void + { + // A JWT-shaped bearer should fall through to the future JwtSessionStrategy, + // not be claimed by this one. + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer eyJhbGciOiJSUzI1NiJ9.payload.sig'; + $this->assertFalse($this->strategy->matches()); + } + + public function testMatchesReadsRedirectAuthorizationFallback(): void + { + // Apache+FPM without the Authorization-passthrough rewrite exposes the + // header in REDIRECT_HTTP_AUTHORIZATION. We accept both. + $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] = 'Bearer ' . self::RAW_TOKEN; + $this->assertTrue($this->strategy->matches()); + } + + public function testPreAuthHappyPathPopulatesAccountAndScopes(): void + { + $this->setAuthHeader(self::RAW_TOKEN); + + $this->strategy->preAuth($this->api); + + $this->assertSame(self::FIXED_ACCOUNT, $this->api->account->number); + $this->assertSame(['read', 'draft'], $this->strategy->scopes); + $this->assertNotNull($this->strategy->token); + $this->assertSame(hash('sha256', self::RAW_TOKEN), $this->strategy->token->token_hash); + } + + public function testPreAuthUpdatesLastUsedAtAndIp(): void + { + $this->setAuthHeader(self::RAW_TOKEN); + $before = time(); + + $this->strategy->preAuth($this->api); + + $fresh = new \Kyte\Core\ModelObject(KyteMCPToken); + $fresh->retrieve('token_hash', hash('sha256', self::RAW_TOKEN)); + $this->assertGreaterThanOrEqual($before, (int)$fresh->last_used_at); + $this->assertSame(self::CLIENT_IP, $fresh->last_used_ip); + } + + public function testPreAuthLogsMcpTokenUseAuditRow(): void + { + $this->setAuthHeader(self::RAW_TOKEN); + $this->strategy->preAuth($this->api); + + $rows = \Kyte\Core\DBI::query( + "SELECT * FROM `KyteActivityLog` WHERE action = 'MCP_TOKEN_USE' ORDER BY id DESC LIMIT 1" + ); + $this->assertNotEmpty($rows, 'Expected an MCP_TOKEN_USE row after successful auth'); + + $row = $rows[0]; + $this->assertSame('KyteMCPToken', $row['model_name']); + $this->assertSame('token_prefix', $row['field']); + $this->assertSame(substr(self::RAW_TOKEN, 0, 16), $row['value']); + $this->assertSame('200', (string)$row['response_code']); + $this->assertSame('authenticated', $row['response_status']); + $this->assertSame((int)$this->strategy->token->id, (int)$row['record_id']); + + $payload = json_decode($row['request_data'], true); + $this->assertSame(['read', 'draft'], $payload['scopes']); + $this->assertSame(self::CLIENT_IP, $payload['ip']); + } + + public function testPreAuthRejectsUnknownToken(): void + { + $this->setAuthHeader('kmcp_live_does_not_exist_xxxxxxxxxxxx'); + + $this->expectException(\Kyte\Exception\SessionException::class); + $this->expectExceptionMessage('Invalid MCP token'); + $this->strategy->preAuth($this->api); + } + + public function testPreAuthRejectsRevokedToken(): void + { + $this->setAuthHeader(self::REVOKED_RAW); + + $this->expectException(\Kyte\Exception\SessionException::class); + $this->expectExceptionMessage('revoked'); + $this->strategy->preAuth($this->api); + } + + public function testPreAuthRejectsExpiredToken(): void + { + $this->setAuthHeader(self::EXPIRED_RAW); + + $this->expectException(\Kyte\Exception\SessionException::class); + $this->expectExceptionMessage('expired'); + $this->strategy->preAuth($this->api); + } + + public function testPreAuthAcceptsClientIpInsideAllowlist(): void + { + $this->setAuthHeader(self::IP_RESTRICTED_RAW); + $_SERVER['REMOTE_ADDR'] = '203.0.113.7'; + + $this->strategy->preAuth($this->api); + $this->assertSame(self::FIXED_ACCOUNT, $this->api->account->number); + } + + public function testPreAuthRejectsClientIpOutsideAllowlist(): void + { + $this->setAuthHeader(self::IP_RESTRICTED_RAW); + $_SERVER['REMOTE_ADDR'] = '198.51.100.7'; + + $this->expectException(\Kyte\Exception\SessionException::class); + $this->expectExceptionMessage('not permitted from this source IP'); + $this->strategy->preAuth($this->api); + } + + public function testPreAuthRejectsMalformedBearerHeader(): void + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer something_without_prefix'; + + $this->expectException(\Kyte\Exception\SessionException::class); + $this->expectExceptionMessage('missing or malformed'); + $this->strategy->preAuth($this->api); + } + + public function testVerifyIsNoOp(): void + { + $this->setAuthHeader(self::RAW_TOKEN); + $this->strategy->preAuth($this->api); + $this->strategy->verify($this->api); + $this->addToAssertionCount(1); + } + + public function testNameIsMcpToken(): void + { + $this->assertSame('mcp_token', $this->strategy->name()); + } +} diff --git a/tests/ModelTest.php b/tests/ModelTest.php index 83b93caf..66bee58b 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -5,6 +5,16 @@ class ModelTest extends TestCase { + protected function setUp(): void + { + // Instantiating Api defines the constants ModelObject reads at write + // time (STRICT_TYPING etc.). Previously this test only worked when + // run as part of the full suite — some other test class would have + // already constructed Api. Doing it here makes the file runnable in + // isolation (e.g. `phpunit --filter ModelTest`). + new \Kyte\Core\Api(); + } + public function testInitDB() { $this->assertIsObject(\Kyte\Core\DBI::dbInit(KYTE_DB_USERNAME, KYTE_DB_PASSWORD, KYTE_DB_HOST, KYTE_DB_DATABASE, KYTE_DB_CHARSET, 'InnoDB')); } @@ -108,6 +118,13 @@ public function testCreateTable() { define('TestTable', $TestTable); + // Drop any prior TestTable so re-runs against the same MariaDB + // container start clean. Without this, rows from previous runs + // accumulate and the count assertions below (2, 3, etc.) start + // failing off-by-N. Surfaced when ModelTest is re-run without + // tearing down the docker container. + \Kyte\Core\DBI::query("DROP TABLE IF EXISTS `TestTable`"); + $this->assertTrue(\Kyte\Core\DBI::createTable(TestTable)); // create entry diff --git a/tests/RefreshTokenStoreTest.php b/tests/RefreshTokenStoreTest.php new file mode 100644 index 00000000..447a02e8 --- /dev/null +++ b/tests/RefreshTokenStoreTest.php @@ -0,0 +1,216 @@ +api = new Api(); + + \Kyte\Core\DBI::createTable(KyteAccount); + \Kyte\Core\DBI::createTable(KyteUser); + \Kyte\Core\DBI::createTable(KyteRefreshToken); + + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number = '" . self::ACCOUNT . "'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteUser` WHERE email IN ('jwt-refresh-test@example.com', 'jwt-refresh-test-other@example.com')"); + \Kyte\Core\DBI::query("DELETE FROM `KyteRefreshToken` WHERE token_prefix LIKE 'kref_v1_%'"); + + $acct = new \Kyte\Core\ModelObject(KyteAccount); + $acct->create(['number' => self::ACCOUNT, 'name' => 'Refresh Store Test']); + $this->accountId = (int)$acct->id; + + $user = new \Kyte\Core\ModelObject(KyteUser); + $user->create([ + 'name' => 'Refresh Test User', + 'email' => 'jwt-refresh-test@example.com', + 'password' => password_hash('placeholder', PASSWORD_DEFAULT), + 'kyte_account' => $this->accountId, + ]); + $this->userId = (int)$user->id; + + $other = new \Kyte\Core\ModelObject(KyteUser); + $other->create([ + 'name' => 'Other Refresh Test User', + 'email' => 'jwt-refresh-test-other@example.com', + 'password' => password_hash('placeholder', PASSWORD_DEFAULT), + 'kyte_account' => $this->accountId, + ]); + $this->otherUserId = (int)$other->id; + } + + public function testIssueReturnsRawAndPersistsHashedRow(): void + { + $result = RefreshTokenStore::issue($this->userId, $this->accountId, null, '1.2.3.4'); + + $this->assertArrayHasKey('raw', $result); + $this->assertArrayHasKey('id', $result); + $this->assertArrayHasKey('family', $result); + $this->assertArrayHasKey('expires_at', $result); + $this->assertStringStartsWith('kref_v1_', $result['raw']); + $this->assertSame(64, strlen($result['family']), 'family is 64 hex chars'); + + $row = $this->loadById($result['id']); + $this->assertSame(hash('sha256', $result['raw']), $row['token_hash']); + $this->assertSame(0, (int)$row['revoked_at']); + $this->assertSame((string)$this->userId, $row['user']); + $this->assertGreaterThan(time(), (int)$row['expires_at']); + } + + public function testRotateRevokesOldAndIssuesSuccessorInSameFamily(): void + { + $original = RefreshTokenStore::issue($this->userId, $this->accountId, null); + $successor = RefreshTokenStore::rotate($original['raw'], '5.6.7.8'); + + $this->assertNotSame($original['raw'], $successor['raw']); + $this->assertSame($original['family'], $successor['family'], 'family preserved across rotation'); + + $oldRow = $this->loadById($original['id']); + $this->assertNotSame(0, (int)$oldRow['revoked_at'], 'original token revoked'); + $this->assertSame('rotated', $oldRow['revoked_reason']); + $this->assertSame((string)$successor['id'], $oldRow['rotated_to'], 'rotation chain recorded'); + $this->assertSame('5.6.7.8', $oldRow['last_used_ip']); + } + + public function testRotateOnRevokedTokenRevokesEntireFamily(): void + { + // Issue, rotate once (revokes original, issues successor). + $original = RefreshTokenStore::issue($this->userId, $this->accountId, null); + $successor = RefreshTokenStore::rotate($original['raw']); + + // Present the original AGAIN — that's reuse. Family revocation expected. + try { + RefreshTokenStore::rotate($original['raw']); + $this->fail('Reused refresh token should have thrown.'); + } catch (SessionException $e) { + $this->assertStringContainsString('reuse detected', strtolower($e->getMessage())); + } + + // Successor should now also be revoked with reason='reuse_detected'. + $successorRow = $this->loadById($successor['id']); + $this->assertNotSame(0, (int)$successorRow['revoked_at']); + $this->assertSame('reuse_detected', $successorRow['revoked_reason']); + } + + public function testRotateOnExpiredTokenMarksExpiredAndDoesNotKillFamily(): void + { + $token = RefreshTokenStore::issue($this->userId, $this->accountId, null); + + // Force expiry in the DB. + \Kyte\Core\DBI::query( + "UPDATE `KyteRefreshToken` SET expires_at = " . (time() - 3600) . " WHERE id = " . (int)$token['id'] + ); + + try { + RefreshTokenStore::rotate($token['raw']); + $this->fail('Expired token should have thrown.'); + } catch (SessionException $e) { + $this->assertStringContainsString('expired', strtolower($e->getMessage())); + } + + $row = $this->loadById($token['id']); + $this->assertSame('expired', $row['revoked_reason'], 'expired tokens marked with expired reason, NOT reuse_detected'); + } + + public function testRotateOnUnknownTokenThrows(): void + { + $this->expectException(SessionException::class); + RefreshTokenStore::rotate('kref_v1_definitely-not-a-real-token'); + } + + public function testRevokeByTokenIsIdempotent(): void + { + $token = RefreshTokenStore::issue($this->userId, $this->accountId, null); + + $this->assertTrue(RefreshTokenStore::revokeByToken($token['raw'], 'logout')); + $row1 = $this->loadById($token['id']); + $this->assertNotSame(0, (int)$row1['revoked_at']); + $firstRevokedAt = (int)$row1['revoked_at']; + + // Second call: no-op, returns true (idempotent). + $this->assertTrue(RefreshTokenStore::revokeByToken($token['raw'], 'logout')); + $row2 = $this->loadById($token['id']); + $this->assertSame($firstRevokedAt, (int)$row2['revoked_at'], 're-revoke does not change timestamp'); + } + + public function testRevokeFamilyHitsAllActiveInFamily(): void + { + // Issue + rotate twice in same family. + $a = RefreshTokenStore::issue($this->userId, $this->accountId, null); + $b = RefreshTokenStore::rotate($a['raw']); + $c = RefreshTokenStore::rotate($b['raw']); + // After two rotations: a and b are revoked (rotated), c is active. + + $count = RefreshTokenStore::revokeFamily($a['family'], 'admin_revoke'); + + // Only c was active and should have been revoked here. + $this->assertSame(1, $count, 'only the active token in the family gets newly-revoked'); + $cRow = $this->loadById($c['id']); + $this->assertSame('admin_revoke', $cRow['revoked_reason']); + } + + public function testRevokeAllForUserSpansFamilies(): void + { + // Two separate logins for the same user → two families. + $login1 = RefreshTokenStore::issue($this->userId, $this->accountId, null); + $login2 = RefreshTokenStore::issue($this->userId, $this->accountId, null); + $this->assertNotSame($login1['family'], $login2['family'], 'separate logins get separate families'); + + // A login for a DIFFERENT user — must not be affected. + $otherLogin = RefreshTokenStore::issue($this->otherUserId, $this->accountId, null); + + $count = RefreshTokenStore::revokeAllForUser($this->userId, $this->accountId); + $this->assertSame(2, $count, 'both of this user\'s active tokens revoked'); + + $otherRow = $this->loadById($otherLogin['id']); + $this->assertSame(0, (int)$otherRow['revoked_at'], 'other user\'s token untouched'); + } + + public function testMultipleLoginsGetSeparateFamilies(): void + { + $login1 = RefreshTokenStore::issue($this->userId, $this->accountId, null); + $login2 = RefreshTokenStore::issue($this->userId, $this->accountId, null); + + $this->assertNotSame($login1['family'], $login2['family']); + + // Revoking one family should not touch the other. + RefreshTokenStore::revokeFamily($login1['family'], 'logout'); + $row2 = $this->loadById($login2['id']); + $this->assertSame(0, (int)$row2['revoked_at']); + } + + private function loadById(int $id): array + { + $rows = \Kyte\Core\DBI::query("SELECT * FROM `KyteRefreshToken` WHERE id = " . $id); + $this->assertNotEmpty($rows, "expected refresh token row id=$id"); + return $rows[0]; + } +} diff --git a/tests/SaveSafeSessionTest.php b/tests/SaveSafeSessionTest.php new file mode 100644 index 00000000..3119062d --- /dev/null +++ b/tests/SaveSafeSessionTest.php @@ -0,0 +1,69 @@ +save(); + + $this->assertTrue($result); + $this->assertTrue($store->exists($session->getId())); + + // Persisted payload should be the empty record (since nothing + // was set), not null/false. + $loaded = $store->read($session->getId()); + $this->assertSame('[]', $loaded); + } + + public function testSavePreservesSetValues(): void + { + $store = new InMemorySessionStore(3600); + $session = new SaveSafeSession($store, new UuidV4()); + + $session->set('initialized', true); + $session->set('client_info.name', 'phpunit'); + + $this->assertTrue($session->save()); + + $payload = json_decode($store->read($session->getId()), true); + $this->assertSame(true, $payload['initialized']); + $this->assertSame('phpunit', $payload['client_info']['name']); + } + + public function testFactoryProducesSaveSafeSession(): void + { + $factory = new SaveSafeSessionFactory(); + $store = new InMemorySessionStore(3600); + + $created = $factory->create($store); + $this->assertInstanceOf(SaveSafeSession::class, $created); + + $id = new UuidV4(); + $rebuilt = $factory->createWithId($id, $store); + $this->assertInstanceOf(SaveSafeSession::class, $rebuilt); + $this->assertSame($id->toRfc4122(), $rebuilt->getId()->toRfc4122()); + } +} diff --git a/tests/SensitivityPolicyTest.php b/tests/SensitivityPolicyTest.php new file mode 100644 index 00000000..8d7ffb79 --- /dev/null +++ b/tests/SensitivityPolicyTest.php @@ -0,0 +1,246 @@ +api = new Api(); + + \Kyte\Core\DBI::createTable(KyteAccount); + \Kyte\Core\DBI::createTable(Application); + \Kyte\Core\DBI::createTable(Controller); + \Kyte\Core\DBI::createTable(DataModel); + \Kyte\Core\DBI::createTable(ModelAttribute); + + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number IN ('" . self::OWN_ACCOUNT . "','" . self::OTHER_ACCOUNT . "')"); + \Kyte\Core\DBI::query("DELETE FROM `Controller` WHERE name LIKE 'SensTestCtrl%'"); + \Kyte\Core\DBI::query("DELETE FROM `DataModel` WHERE name LIKE 'SensTestModel%'"); + \Kyte\Core\DBI::query("DELETE FROM `ModelAttribute` WHERE name LIKE 'sens_test_%'"); + + $this->ownAccountId = $this->createAccount(self::OWN_ACCOUNT, 'Own'); + $this->otherAccountId = $this->createAccount(self::OTHER_ACCOUNT, 'Other'); + + $this->createController('SensTestCtrlSensitive', $this->ownAccountId, 1); + $this->createController('SensTestCtrlPlain', $this->ownAccountId, 0); + // Same controller name under a different account — must not leak across. + $this->createController('SensTestCtrlSensitive', $this->otherAccountId, 0); + + $this->sensitiveModelId = $this->createDataModel('SensTestModelSensitive', $this->ownAccountId, 1); + $this->plainModelId = $this->createDataModel('SensTestModelPlain', $this->ownAccountId, 0); + + // Two sensitive fields and one plain field on the plain model. + $this->createAttribute('sens_test_alpha', $this->plainModelId, $this->ownAccountId, 1); + $this->createAttribute('sens_test_beta', $this->plainModelId, $this->ownAccountId, 1); + $this->createAttribute('sens_test_locale', $this->plainModelId, $this->ownAccountId, 0); + + SensitivityPolicy::resetForTests(); + } + + public function testControllerSensitiveFlagDetected(): void + { + $policy = SensitivityPolicy::getInstance(); + $this->assertTrue($policy->isControllerSensitive('SensTestCtrlSensitive', $this->ownAccountId)); + } + + public function testControllerWithoutFlagIsNotSensitive(): void + { + $policy = SensitivityPolicy::getInstance(); + $this->assertFalse($policy->isControllerSensitive('SensTestCtrlPlain', $this->ownAccountId)); + } + + public function testForeignAccountControllerWithSameNameDoesNotLeak(): void + { + $policy = SensitivityPolicy::getInstance(); + // Same name 'SensTestCtrlSensitive' exists in the other account with flag=0. + // Querying the other account must return false even though the own account's + // row has flag=1. + $this->assertFalse($policy->isControllerSensitive('SensTestCtrlSensitive', $this->otherAccountId)); + } + + public function testModelSensitiveFlagDetected(): void + { + $policy = SensitivityPolicy::getInstance(); + $this->assertTrue($policy->isModelSensitive('SensTestModelSensitive', $this->ownAccountId)); + } + + public function testModelWithoutFlagIsNotSensitive(): void + { + $policy = SensitivityPolicy::getInstance(); + $this->assertFalse($policy->isModelSensitive('SensTestModelPlain', $this->ownAccountId)); + } + + public function testSensitiveFieldsReturnedForModel(): void + { + $policy = SensitivityPolicy::getInstance(); + $fields = $policy->getSensitiveFields('SensTestModelPlain', $this->ownAccountId); + sort($fields); + $this->assertSame(['sens_test_alpha', 'sens_test_beta'], $fields); + } + + public function testShouldDropPayloadTrueWhenControllerSensitive(): void + { + $policy = SensitivityPolicy::getInstance(); + $this->assertTrue($policy->shouldDropPayload('SensTestCtrlSensitive', null, $this->ownAccountId), + 'Controller-sensitive alone must drop payload, even with no model context (virtual controller case)'); + } + + public function testShouldDropPayloadTrueWhenModelSensitive(): void + { + $policy = SensitivityPolicy::getInstance(); + $this->assertTrue($policy->shouldDropPayload(null, 'SensTestModelSensitive', $this->ownAccountId)); + } + + public function testShouldDropPayloadFalseWhenNeitherFlagged(): void + { + $policy = SensitivityPolicy::getInstance(); + $this->assertFalse($policy->shouldDropPayload('SensTestCtrlPlain', 'SensTestModelPlain', $this->ownAccountId)); + } + + public function testRedactFieldsReplacesSensitiveKeysCaseInsensitive(): void + { + $policy = SensitivityPolicy::getInstance(); + $payload = [ + 'name' => 'Example Name', + 'SENS_TEST_ALPHA' => 'redact-me-1', // upper-case key, still redacted + 'sens_test_beta' => 'redact-me-2', + 'sens_test_locale' => 'en-US', + 'note' => 'non-sensitive note', + ]; + $out = $policy->redactFields($payload, 'SensTestModelPlain', $this->ownAccountId); + + $this->assertSame('[REDACTED]', $out['SENS_TEST_ALPHA']); + $this->assertSame('[REDACTED]', $out['sens_test_beta']); + $this->assertSame('en-US', $out['sens_test_locale']); + $this->assertSame('Example Name', $out['name']); + $this->assertSame('non-sensitive note', $out['note']); + } + + public function testRedactFieldsRecursesIntoNestedArrays(): void + { + $policy = SensitivityPolicy::getInstance(); + $payload = [ + 'wrapper' => [ + 'sens_test_alpha' => 'redact-me-nested', + 'inner_ok' => 'keep', + ], + ]; + $out = $policy->redactFields($payload, 'SensTestModelPlain', $this->ownAccountId); + $this->assertSame('[REDACTED]', $out['wrapper']['sens_test_alpha']); + $this->assertSame('keep', $out['wrapper']['inner_ok']); + } + + public function testRedactFieldsNoOpWhenModelIsNull(): void + { + $policy = SensitivityPolicy::getInstance(); + $payload = ['sens_test_alpha' => 'leak']; + // model=null reflects the no-model-context case — the caller is + // responsible for invoking shouldDropPayload() at the controller tier + // first. redactFields can't know which fields are sensitive without + // a model to look up. + $this->assertSame($payload, $policy->redactFields($payload, null, $this->ownAccountId)); + } + + public function testNullArgsReturnPermissive(): void + { + $policy = SensitivityPolicy::getInstance(); + $this->assertFalse($policy->isControllerSensitive(null, $this->ownAccountId)); + $this->assertFalse($policy->isControllerSensitive('SensTestCtrlSensitive', null)); + $this->assertFalse($policy->isModelSensitive(null, $this->ownAccountId)); + $this->assertSame([], $policy->getSensitiveFields(null, $this->ownAccountId)); + } + + public function testPerRequestCacheIsHonored(): void + { + $policy = SensitivityPolicy::getInstance(); + // First call populates the cache. + $this->assertTrue($policy->isControllerSensitive('SensTestCtrlSensitive', $this->ownAccountId)); + + // Mutate the row out from under the policy. If the cache is honored + // the second call still returns true; if it weren't, this would flip. + \Kyte\Core\DBI::query( + "UPDATE `Controller` SET `sensitive` = 0 WHERE name = 'SensTestCtrlSensitive' AND kyte_account = " . (int)$this->ownAccountId + ); + + $this->assertTrue( + $policy->isControllerSensitive('SensTestCtrlSensitive', $this->ownAccountId), + 'Cache must hold the prior value for the rest of the request' + ); + } + + private function createAccount(string $number, string $name): int + { + $obj = new \Kyte\Core\ModelObject(KyteAccount); + $obj->create(['number' => $number, 'name' => $name]); + return (int)$obj->id; + } + + private function createController(string $name, int $accountId, int $sensitive): int + { + $obj = new \Kyte\Core\ModelObject(Controller); + $obj->create([ + 'name' => $name, + 'sensitive' => $sensitive, + 'kyte_account' => $accountId, + ]); + return (int)$obj->id; + } + + private function createDataModel(string $name, int $accountId, int $sensitive): int + { + $obj = new \Kyte\Core\ModelObject(DataModel); + $obj->create([ + 'name' => $name, + 'sensitive' => $sensitive, + 'kyte_account' => $accountId, + ]); + return (int)$obj->id; + } + + private function createAttribute(string $name, int $dataModelId, int $accountId, int $sensitive): int + { + $obj = new \Kyte\Core\ModelObject(ModelAttribute); + $obj->create([ + 'name' => $name, + 'type' => 's', + 'dataModel' => $dataModelId, + 'sensitive' => $sensitive, + 'kyte_account' => $accountId, + ]); + return (int)$obj->id; + } +}