Files
computer-playbook/roles/web-app-snipe-it/files/php/provision_mcp_identity.php
kevinveenbirkenbachandClaude Opus 5 ee3c485776 fix(snipe-it): mint as apache and hand Passport's keys to it so the API can verify the MCP token
Run 34421944328 Snipe-IT#1 (swarm) failed its CLI contract with
"UNREACHABLE read call snipeit_get_license_summary: the provider
answered 500". The mint ran provision_mcp_identity.php through
container exec as root, so the first deploy's passport:keys wrote the
key files root-owned (0660/0600), and the NFS export's No_Root_Squash
kept them that way. Apache serves as apache and could not read the
public key, so every token-checked request failed with 500 on every
replica. The image's startup script chowns the keys to apache:root,
but only when a container starts, and the mint ran after that.

mcp_mint.yml now runs the same chown in the resolved container before
minting, which also heals keys an earlier deploy left root-owned, and
runs the mint itself with -u apache so keys it creates start out
readable.

The PHP script checks the token it just minted against its own API and
exits 1 on anything but 200, so a broken mint fails the task that made
it, within its retries, instead of surfacing as an unreachable provider
in the CLI contract. Each retry revokes the same-name token before
minting, so retries do not pile up live tokens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 14:04:13 +02:00

129 lines
3.9 KiB
PHP

<?php
/**
* Give the MCP adapter a read-only Snipe-IT user and print its access token.
*
* Passport signs its tokens, so the deployment cannot pin one. What it pins is
* the user and its permission group; the token is captured from the mint that
* follows and handed to the adapter through its env file.
*
* Snipe-IT's image never provisions Passport's keys or its personal-access
* client, so createToken() would throw on a fresh deployment. Both are ensured
* here before anything is minted.
*
* Prints CHANGED or UNCHANGED on the first line and the token on the second.
* Exits 1 when Snipe-IT's own API refuses the token it just minted.
*
* Environment:
* MCP_USER: username to converge.
* MCP_PASSWORD: password to pin on it.
* MCP_TOKEN_NAME: personal access token name to converge.
* MCP_CURRENT_TOKEN: token the adapter holds today, or "" for none.
*/
// nocheck: mirrored-unit-test - boots Snipe-IT's Laravel kernel through
// bootstrap/app.php and mints through Passport against the live database; neither the
// autoloader nor the models exist outside the Snipe-IT image
require "vendor/autoload.php";
$app = require "bootstrap/app.php";
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
$username = getenv("MCP_USER");
$password = getenv("MCP_PASSWORD");
$tokenName = getenv("MCP_TOKEN_NAME");
$currentToken = trim((string) getenv("MCP_CURRENT_TOKEN"));
$permissions = json_encode([
"self.api" => "1",
"assets.view" => "1",
"licenses.view" => "1",
]);
function apiStatus(string $token): int
{
$probe = curl_init("http://127.0.0.1/api/v1/hardware?limit=1");
curl_setopt_array($probe, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . $token,
"Accept: application/json",
],
CURLOPT_TIMEOUT => 20,
]);
curl_exec($probe);
return (int) curl_getinfo($probe, CURLINFO_HTTP_CODE);
}
if ($currentToken !== "" && apiStatus($currentToken) === 200) {
echo "UNCHANGED\n";
echo $currentToken . "\n";
exit(0);
}
$changed = false;
if (!file_exists(storage_path("oauth-private.key"))) {
Artisan::call("passport:keys", ["--no-interaction" => true]);
$changed = true;
}
if (!DB::table("oauth_personal_access_clients")->exists()) {
Artisan::call("passport:client", [
"--personal" => true,
"--name" => "Infinito personal access client",
"--no-interaction" => true,
]);
$changed = true;
}
$group = \App\Models\Group::where("name", "mcp-readonly")->first();
if (!$group) {
$group = \App\Models\Group::create([
"name" => "mcp-readonly",
"permissions" => $permissions,
]);
$changed = true;
} elseif ($group->permissions !== $permissions) {
$group->permissions = $permissions;
$group->save();
$changed = true;
}
$user = \App\Models\User::where("username", $username)->whereNull("deleted_at")->first();
if (!$user) {
$user = new \App\Models\User();
$user->first_name = "MCP";
$user->last_name = "Adapter";
$user->username = $username;
$user->activated = 1;
$user->password = bcrypt($password);
$user->save();
$changed = true;
} elseif (!\Illuminate\Support\Facades\Hash::check($password, (string) $user->password)) {
$user->password = bcrypt($password);
$user->save();
$changed = true;
}
$user->groups()->sync([$group->id]);
$revoked = DB::table("oauth_access_tokens")
->where("user_id", $user->id)
->where("name", $tokenName)
->where("revoked", 0)
->update(["revoked" => 1]);
$changed = $changed || $revoked > 0;
$token = $user->createToken($tokenName)->accessToken;
$status = apiStatus($token);
if ($status !== 200) {
fwrite(STDERR, "Snipe-IT's own API answered {$status} to the token it just minted\n");
exit(1);
}
echo ($changed ? "CHANGED" : "UNCHANGED") . "\n";
echo $token . "\n";