Issue machine tokens with limited permissions and an expiry

A token acts as the person who made it, within the permissions it was given
and those they still hold, checked on every request. Permission checks now go
through one gate rule, since spatie's answered first and never asked the
token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 09:21:37 +02:00
parent 0119004f2c
commit f93775e549
18 changed files with 748 additions and 17 deletions

View File

@@ -1348,6 +1348,31 @@ cliente en vez de comprobar nombres de rol.
Quien no tenga `pages.publish` solo ve páginas publicadas en el listado. Quien no tenga `pages.publish` solo ve páginas publicadas en el listado.
### Tokens para máquinas
```
GET /api/v1/tokens mis tokens (all=1 para un super_admin)
POST /api/v1/tokens {name, abilities, expires_at?} → el token, una vez
DELETE /api/v1/tokens/{id} revocar
```
Para scripts, CI o integraciones que no deberían usar la contraseña de nadie.
Una persona lo crea con los permisos que le da (`pages.update`, o `pages.*`
para todo lo de páginas) y nunca más de los que ella tiene; caduca a los 90
días si no se dice otra cosa, y como mucho al año. Se guarda solo su hash, y el
token en claro (`pmt_…`) sale una única vez, en la respuesta que lo crea.
Actúa como esa persona **dentro** de sus permisos, y se comprueba en cada
petición, así que quitarle un rol recorta sus tokens al momento; desactivarla
los apaga. El guard lo reconoce por el prefijo y **no lo manda nunca al
servidor de identidad**. Un token no puede crear ni revocar tokens: si no, uno
filtrado sería una forma de no caducar nunca.
Para que eso funcione, la comprobación de permisos es ahora una sola regla en
`AuthServiceProvider` y la de spatie está apagada
(`register_permission_check_method => false`): la de spatie respondía antes y
concedía el permiso sin preguntar al token.
### Lotes e importación ### Lotes e importación
``` ```
@@ -1555,7 +1580,9 @@ variables, porque quien lo lanza es quien inicia sesión:
LANDING_URL raíz de la aplicación (por defecto http://localhost:8000) LANDING_URL raíz de la aplicación (por defecto http://localhost:8000)
LANDING_USERNAME credenciales de Pireon para la API de administración; LANDING_USERNAME credenciales de Pireon para la API de administración;
LANDING_PASSWORD el token se pide y se refresca solo LANDING_PASSWORD el token se pide y se refresca solo
LANDING_TOKEN o un bearer token fijo en vez de credenciales LANDING_TOKEN o un bearer token fijo en vez de credenciales —
lo natural es un token de máquina (pmt_…) con
solo los permisos que el MCP necesita
LANDING_PREVIEW_TOKEN opcional: las herramientas públicas ven borradores LANDING_PREVIEW_TOKEN opcional: las herramientas públicas ven borradores
LANDING_OPENAPI opcional: ruta o URL de la especificación LANDING_OPENAPI opcional: ruta o URL de la especificación
``` ```

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Auth; namespace App\Auth;
use App\Actions\Auth\SyncPireonUser; use App\Actions\Auth\SyncPireonUser;
use App\Models\ApiToken;
use App\Models\User; use App\Models\User;
use App\Services\Pireon\PireonClient; use App\Services\Pireon\PireonClient;
use App\Services\Pireon\PireonException; use App\Services\Pireon\PireonException;
@@ -35,7 +36,16 @@ class PireonTokenGuard
{ {
$token = $request->bearerToken(); $token = $request->bearerToken();
if (blank($token) || ! $this->client->isConfigured()) { if (blank($token)) {
return null;
}
// Ours, not the identity server's: checked here and never sent there.
if (str_starts_with($token, ApiToken::PREFIX)) {
return $this->machine($token);
}
if (! $this->client->isConfigured()) {
return null; return null;
} }
@@ -52,6 +62,21 @@ class PireonTokenGuard
return $user->is_active ? $user : null; return $user->is_active ? $user : null;
} }
protected function machine(string $plain): ?User
{
$token = ApiToken::findUsable($plain);
$user = $token?->user;
// A person switched off here takes their tokens with them.
if ($user === null || ! $user->is_active) {
return null;
}
$token->touchLastUsed();
return $user->withAccessToken($token);
}
protected function resolveIdentity(string $token): ?PireonIdentity protected function resolveIdentity(string $token): ?PireonIdentity
{ {
$key = 'pireon:introspect:'.hash('sha256', $token); $key = 'pireon:introspect:'.hash('sha256', $token);

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\ApiTokenResource;
use App\Models\ApiToken;
use App\Models\User;
use App\Support\Permissions;
use Carbon\CarbonImmutable;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Str;
/**
* Machine tokens: made by a person, for something that is not one.
*
* Only a person signed in through the identity server manages them — a
* machine token cannot make another, or a leaked one would be a way of never
* expiring.
*/
class ApiTokenController extends Controller
{
/**
* The caller's own tokens, newest first. A super admin may ask for
* everyone's with `all`.
*/
public function index(Request $request): AnonymousResourceCollection
{
$user = $this->person($request);
$tokens = ApiToken::query()
->with('user')
->unless($request->boolean('all') && $user->hasRole('super_admin'), fn ($q) => $q->whereBelongsTo($user))
->latest('id')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return ApiTokenResource::collection($tokens);
}
public function store(Request $request): JsonResponse
{
$user = $this->person($request);
$data = $request->validate([
'name' => ['required', 'string', 'max:120'],
'abilities' => ['required', 'array', 'min:1'],
'abilities.*' => [
'string',
'distinct',
// Only what the person could do themselves: a token narrows,
// it never widens.
function (string $attribute, mixed $value, Closure $fail) use ($user): void {
$matches = array_filter(Permissions::all(), fn (string $permission) => $permission === $value || Str::is((string) $value, $permission));
if ($matches === [] || ! collect($matches)->contains(fn (string $permission) => $user->can($permission))) {
$fail(__('rules.tokens.ability', ['ability' => $value]));
}
},
],
'expires_at' => ['sometimes', 'date', 'after:now', 'before_or_equal:'.now()->addDays(ApiToken::MAX_DAYS)->toIso8601String()],
]);
[$token, $plain] = ApiToken::issue(
$user,
$data['name'],
$data['abilities'],
isset($data['expires_at']) ? CarbonImmutable::parse($data['expires_at']) : now()->addDays(90),
);
return ApiTokenResource::make($token->load('user'))
->additional(['token' => $plain])
->response()
->setStatusCode(201);
}
/**
* Revoke. Kept in the list afterwards, so it is clear what stopped working.
*/
public function destroy(Request $request, ApiToken $token): JsonResponse
{
$user = $this->person($request);
abort_unless($token->user_id === $user->id || $user->hasRole('super_admin'), 403);
$token->revoke();
return response()->json(status: 204);
}
protected function person(Request $request): User
{
$user = $request->user();
abort_if($user->currentAccessToken() !== null, 403, __('rules.tokens.machine'));
return $user;
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\ApiToken;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Never the token itself, which only exists in the answer that made it.
*
* @mixin ApiToken
*/
class ApiTokenResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'prefix' => $this->prefix,
'abilities' => $this->abilities ?? [],
'owner' => ['id' => $this->user_id, 'name' => $this->user?->name],
'status' => match (true) {
$this->revoked_at !== null => 'revoked',
$this->expires_at->isPast() => 'expired',
default => 'active',
},
'expires_at' => $this->expires_at->toIso8601String(),
'last_used_at' => $this->last_used_at?->toIso8601String(),
'revoked_at' => $this->revoked_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Models\User; use App\Models\User;
use App\Support\Permissions;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -35,7 +36,10 @@ class UserResource extends JsonResource
'roles' => $this->whenLoaded('roles', fn () => $this->roles->pluck('name')->values()), 'roles' => $this->whenLoaded('roles', fn () => $this->roles->pluck('name')->values()),
'permissions' => $this->when( 'permissions' => $this->when(
$request->routeIs('api.v1.me'), $request->routeIs('api.v1.me'),
fn () => $this->getAllPermissions()->pluck('name')->values(), // Through a machine token, only what the token may do.
fn () => $this->resource->currentAccessToken()
? collect(Permissions::all())->filter(fn (string $permission) => $this->resource->can($permission))->values()
: $this->getAllPermissions()->pluck('name')->values(),
), ),
// Roles reported by the identity server. Informational only: they // Roles reported by the identity server. Informational only: they

128
app/Models/ApiToken.php Normal file
View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
/**
* An access token for a machine.
*
* It acts as the person who made it, and never as more: what it may do is the
* intersection of its `abilities` and that person's own permissions, checked
* on every request — so taking a role away from them narrows their tokens at
* once. It always expires, and only its hash is stored.
*/
#[Fillable(['name', 'abilities', 'expires_at'])]
class ApiToken extends Model
{
/**
* What tells this application's tokens from the identity server's, so a
* machine token is never sent upstream to be checked.
*/
public const PREFIX = 'pmt_';
public const MAX_DAYS = 365;
protected function casts(): array
{
return [
'abilities' => 'array',
'expires_at' => 'datetime',
'last_used_at' => 'datetime',
'revoked_at' => 'datetime',
];
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Make one, and return the plain token alongside: the only time it exists
* anywhere but in the caller's hands.
*
* @param array<int, string> $abilities
* @return array{0: self, 1: string}
*/
public static function issue(User $owner, string $name, array $abilities, CarbonInterface $expiresAt): array
{
$plain = self::PREFIX.Str::random(40);
$token = new self(['name' => $name, 'abilities' => array_values($abilities), 'expires_at' => $expiresAt]);
$token->user()->associate($owner);
$token->forceFill([
'token_hash' => self::hash($plain),
'prefix' => substr($plain, 0, 12),
])->save();
return [$token, $plain];
}
public static function hash(string $plain): string
{
return hash('sha256', $plain);
}
public static function findUsable(string $plain): ?self
{
$token = self::query()->with('user')->where('token_hash', self::hash($plain))->first();
return $token?->isUsable() ? $token : null;
}
public function isUsable(): bool
{
return $this->revoked_at === null && $this->expires_at->isFuture();
}
/**
* Whether the token was given this permission. `pages.*` covers every
* permission on pages.
*/
public function allows(string $permission): bool
{
foreach ($this->abilities ?? [] as $ability) {
if ($ability === $permission || Str::is($ability, $permission)) {
return true;
}
}
return false;
}
/**
* Recorded at most once a minute: a busy script would otherwise turn
* every request into a write.
*/
public function touchLastUsed(): void
{
if ($this->last_used_at === null || $this->last_used_at->lt(now()->subMinute())) {
$this->forceFill(['last_used_at' => now()])->saveQuietly();
}
}
public function revoke(): void
{
$this->forceFill(['revoked_at' => now()])->save();
}
/**
* @param Builder<$this> $query
* @return Builder<$this>
*/
public function scopeUsable(Builder $query): Builder
{
return $query->whereNull('revoked_at')->where('expires_at', '>', now());
}
}

View File

@@ -42,6 +42,32 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasName
*/ */
protected string $guard_name = 'web'; protected string $guard_name = 'web';
/**
* The machine token this request came in with, when it did. Not stored:
* it describes the request, not the person.
*/
protected ?ApiToken $accessToken = null;
public function withAccessToken(?ApiToken $token): static
{
$this->accessToken = $token;
return $this;
}
public function currentAccessToken(): ?ApiToken
{
return $this->accessToken;
}
/**
* @return HasMany<ApiToken, $this>
*/
public function apiTokens(): HasMany
{
return $this->hasMany(ApiToken::class);
}
/** /**
* Get the attributes that should be cast. * Get the attributes that should be cast.
* *

View File

@@ -37,6 +37,7 @@ use App\Policies\TalkPolicy;
use App\Policies\TechnologyPolicy; use App\Policies\TechnologyPolicy;
use App\Policies\TestimonialPolicy; use App\Policies\TestimonialPolicy;
use App\Policies\UserPolicy; use App\Policies\UserPolicy;
use App\Support\Permissions;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Role;
@@ -63,12 +64,35 @@ class AuthServiceProvider extends ServiceProvider
Gate::policy(User::class, UserPolicy::class); Gate::policy(User::class, UserPolicy::class);
Gate::policy(Role::class, RolePolicy::class); Gate::policy(Role::class, RolePolicy::class);
// Super admins bypass every permission check. Granting the role this // The one place a permission is granted — spatie's own gate check is
// way rather than by attaching every permission means the role can // switched off (config/permission.php) so nothing answers before this.
// never fall behind as the catalogue grows.
// //
// Returning null (not false) when the role is absent is important: it // Super admins bypass every check. Granting the role this way rather
// lets the normal policy chain run instead of short-circuiting it. // than by attaching every permission means the role can never fall
Gate::before(fn (User $user, string $ability) => $user->hasRole('super_admin') ? true : null); // behind as the catalogue grows. Returning null (not false) when
// nothing applies lets the normal policy chain run.
//
// A machine token narrows all of that: a permission must be one the
// token was given *and* one its owner holds. A policy is not a
// permission, so it runs as usual and asks for permissions, which come
// back here.
Gate::before(function (User $user, string $ability): ?bool {
$token = $user->currentAccessToken();
if ($token !== null) {
if (! in_array($ability, Permissions::all(), true)) {
return null;
}
return $token->allows($ability)
&& ($user->hasRole('super_admin') || $user->checkPermissionTo($ability));
}
if ($user->hasRole('super_admin')) {
return true;
}
return $user->checkPermissionTo($ability) ?: null;
});
} }
} }

View File

@@ -105,7 +105,10 @@ return [
* Set this to false if you want to implement custom logic for checking permissions. * Set this to false if you want to implement custom logic for checking permissions.
*/ */
'register_permission_check_method' => true, // Off: App\Providers\AuthServiceProvider registers the one check, so a
// machine token's narrower abilities are asked before any permission is
// granted rather than after.
'register_permission_check_method' => false,
/* /*
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered * When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Access tokens for machines: scripts, CI, an integration — anything that
* should reach the admin API without a person's password and with less than
* that person may do.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('api_tokens', function (Blueprint $table): void {
$table->id();
// Whose permissions it borrows from. Deleting the person takes
// their tokens with them.
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
// Only the hash is kept: the token is shown once, when it is made.
$table->string('token_hash', 64)->unique();
// The first characters, to tell tokens apart in a list.
$table->string('prefix', 12);
$table->jsonb('abilities');
$table->timestamp('expires_at');
$table->timestamp('last_used_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('api_tokens');
}
};

View File

@@ -44,6 +44,11 @@ return [
'too_many' => 'Com a molt :max files per importació.', 'too_many' => 'Com a molt :max files per importació.',
], ],
'tokens' => [
'ability' => '«:ability» no és un permís que tinguis, així que un token teu no el pot tenir.',
'machine' => 'Els tokens els gestiona una persona, no un altre token.',
],
'settings' => [ 'settings' => [
'read_only' => 'Aquest ajust l\'escriu l\'aplicació i no es pot editar a mà.', 'read_only' => 'Aquest ajust l\'escriu l\'aplicació i no es pot editar a mà.',
], ],

View File

@@ -44,6 +44,11 @@ return [
'too_many' => 'At most :max rows per import.', 'too_many' => 'At most :max rows per import.',
], ],
'tokens' => [
'ability' => '":ability" is not a permission you hold, so a token of yours cannot hold it.',
'machine' => 'Tokens are managed by a person, not by another token.',
],
'settings' => [ 'settings' => [
'read_only' => 'This setting is written by the application and cannot be edited by hand.', 'read_only' => 'This setting is written by the application and cannot be edited by hand.',
], ],

View File

@@ -44,6 +44,11 @@ return [
'too_many' => 'Como mucho :max filas por importación.', 'too_many' => 'Como mucho :max filas por importación.',
], ],
'tokens' => [
'ability' => '«:ability» no es un permiso que tengas, así que un token tuyo no puede tenerlo.',
'machine' => 'Los tokens los gestiona una persona, no otro token.',
],
'settings' => [ 'settings' => [
'read_only' => 'Este ajuste lo escribe la aplicación y no se puede editar a mano.', 'read_only' => 'Este ajuste lo escribe la aplicación y no se puede editar a mano.',
], ],

View File

@@ -20,7 +20,8 @@
* *
* LANDING_USERNAME Pireon credentials for the admin API (/v1). The * LANDING_USERNAME Pireon credentials for the admin API (/v1). The
* LANDING_PASSWORD token is obtained and refreshed automatically. * LANDING_PASSWORD token is obtained and refreshed automatically.
* LANDING_TOKEN Or a fixed bearer token instead. * LANDING_TOKEN Or a fixed bearer token instead — a machine token
* (pmt_…) with only the permissions needed.
* *
* HTTP only — every request brings its own credentials (see src/http.mjs): * HTTP only — every request brings its own credentials (see src/http.mjs):
* *

View File

@@ -16,9 +16,8 @@ info:
## Authentication ## Authentication
Every endpoint under `/v1` except `POST /v1/auth/token` requires a bearer token issued Every endpoint under `/v1` except `POST /v1/auth/token` requires a bearer token issued
by the **Pireon identity server** (`https://oauth.pireon.dev`). This by the **Pireon identity server** (`https://oauth.pireon.dev`), which this
application does not issue tokens of its own; it validates the ones the application validates — or a **machine token** of its own.
identity server issued.
Obtain a token through `POST /v1/auth/token`, which proxies the identity Obtain a token through `POST /v1/auth/token`, which proxies the identity
server's password grant. The panel's own sign-in form uses the same grant, server's password grant. The panel's own sign-in form uses the same grant,
@@ -26,6 +25,13 @@ info:
against `https://oauth.pireon.dev/oauth/authorize` remains available for against `https://oauth.pireon.dev/oauth/authorize` remains available for
clients that prefer never to handle a password. clients that prefer never to handle a password.
Machine tokens (`pmt_…`) are for scripts and integrations: a person makes
one with `POST /v1/tokens`, naming the permissions it gets — never more
than their own — and when it expires. It acts as that person within
those permissions, checked on every request, so taking a role away from
them narrows their tokens at once. It is checked here and never sent to
the identity server.
## Authorisation ## Authorisation
The identity server establishes *who* the caller is. What they may *do* is The identity server establishes *who* the caller is. What they may *do* is
@@ -1029,6 +1035,71 @@ paths:
data: { $ref: "#/components/schemas/User" } data: { $ref: "#/components/schemas/User" }
"401": { $ref: "#/components/responses/Unauthorized" } "401": { $ref: "#/components/responses/Unauthorized" }
/v1/tokens:
get:
tags: [Auth]
summary: List my machine tokens
description: Newest first, revoked and expired ones included. A super admin may ask for everyone's with `all`. Not available to a machine token.
parameters:
- $ref: "#/components/parameters/PerPage"
- name: all
in: query
description: "Everyone's tokens. Super admins only."
schema: { type: boolean }
responses:
"200":
description: Paginated tokens
content:
application/json:
schema: { $ref: "#/components/schemas/ApiTokenCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Auth]
summary: Make a machine token
description: |
`abilities` are permission names — `pages.update` — or a whole
resource — `pages.*` — and only ones the caller holds. It expires
after 90 days unless `expires_at` says otherwise, a year at most. The
token itself is in the answer's `token`, and nowhere else ever again.
Not available to a machine token.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name, abilities]
properties:
name: { type: string, maxLength: 120, example: Despliegue continuo }
abilities: { type: array, items: { type: string }, example: ["technologies.*", "media.view_any"] }
expires_at: { type: string, format: date-time }
responses:
"201":
description: Token made
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/ApiToken" }
token: { type: string, description: The token. Shown once. }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/tokens/{token}:
delete:
tags: [Auth]
summary: Revoke a machine token
description: It stops working at once and stays listed as revoked. Your own, or anyone's for a super admin.
parameters:
- name: token
in: path
required: true
schema: { type: integer }
responses:
"204": { description: Revoked }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/pages: /v1/pages:
get: get:
tags: [Pages] tags: [Pages]
@@ -4046,9 +4117,9 @@ components:
type: http type: http
scheme: bearer scheme: bearer
description: | description: |
An access token issued by https://oauth.pireon.dev. Validated on each An access token issued by https://oauth.pireon.dev, validated on each
request against the identity server's `GET /api/user`, with the result request against the identity server's `GET /api/user` with the result
cached briefly. cached briefly — or a machine token (`pmt_…`) from `POST /v1/tokens`.
ingestToken: ingestToken:
type: apiKey type: apiKey
@@ -5542,6 +5613,33 @@ components:
failed: { type: integer } failed: { type: integer }
dry_run: { type: boolean, nullable: true } dry_run: { type: boolean, nullable: true }
ApiToken:
type: object
properties:
id: { type: integer }
name: { type: string }
prefix: { type: string, description: "The first characters, to tell tokens apart." }
abilities: { type: array, items: { type: string } }
owner:
type: object
properties:
id: { type: integer }
name: { type: string, nullable: true }
status: { type: string, enum: [active, expired, revoked] }
expires_at: { type: string, format: date-time }
last_used_at: { type: string, format: date-time, nullable: true }
revoked_at: { type: string, format: date-time, nullable: true }
created_at: { type: string, format: date-time }
ApiTokenCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/ApiToken" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
MediaFor: MediaFor:
type: string type: string
enum: [pages, technologies, projects, members, clients, testimonials] enum: [pages, technologies, projects, members, clients, testimonials]

View File

@@ -11,6 +11,7 @@ use App\Http\Controllers\Api\PublicApi\V1\SearchController;
use App\Http\Controllers\Api\PublicApi\V1\ShowcaseController; use App\Http\Controllers\Api\PublicApi\V1\ShowcaseController;
use App\Http\Controllers\Api\PublicApi\V1\SiteController; use App\Http\Controllers\Api\PublicApi\V1\SiteController;
use App\Http\Controllers\Api\PublicApi\V1\TelemetryController; use App\Http\Controllers\Api\PublicApi\V1\TelemetryController;
use App\Http\Controllers\Api\V1\ApiTokenController;
use App\Http\Controllers\Api\V1\ApplicationController as AdminApplicationController; use App\Http\Controllers\Api\V1\ApplicationController as AdminApplicationController;
use App\Http\Controllers\Api\V1\BatchController; use App\Http\Controllers\Api\V1\BatchController;
use App\Http\Controllers\Api\V1\CallController; use App\Http\Controllers\Api\V1\CallController;
@@ -181,6 +182,11 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::middleware(['auth:api', 'throttle:api'])->group(function () { Route::middleware(['auth:api', 'throttle:api'])->group(function () {
Route::get('me', MeController::class)->name('me'); Route::get('me', MeController::class)->name('me');
// Machine tokens, managed by the person they act for.
Route::get('tokens', [ApiTokenController::class, 'index'])->name('tokens.index');
Route::post('tokens', [ApiTokenController::class, 'store'])->name('tokens.store');
Route::delete('tokens/{token}', [ApiTokenController::class, 'destroy'])->name('tokens.destroy');
Route::apiResource('pages', PageController::class); Route::apiResource('pages', PageController::class);
Route::post('pages/{page}/restore', [PageController::class, 'restore']) Route::post('pages/{page}/restore', [PageController::class, 'restore'])
->withTrashed() ->withTrashed()

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature\Api; namespace Tests\Feature\Api;
use App\Models\ApiToken;
use App\Models\Application; use App\Models\Application;
use App\Models\Call; use App\Models\Call;
use App\Models\Category; use App\Models\Category;
@@ -92,6 +93,7 @@ class AdminApiContractTest extends TestCase
'missing-urls' => ['path' => '/v1/missing-urls'], 'missing-urls' => ['path' => '/v1/missing-urls'],
'repositories' => ['path' => '/v1/repositories'], 'repositories' => ['path' => '/v1/repositories'],
'media' => ['path' => '/v1/media'], 'media' => ['path' => '/v1/media'],
'tokens' => ['path' => '/v1/tokens'],
]; ];
} }
@@ -115,6 +117,7 @@ class AdminApiContractTest extends TestCase
Setting::set(Maintenance::KEY, ['enabled' => false]); Setting::set(Maintenance::KEY, ['enabled' => false]);
$media = Media::factory()->create(); $media = Media::factory()->create();
$page->comment('Primera nota', $this->caller); $page->comment('Primera nota', $this->caller);
ApiToken::issue($this->caller, 'CI', ['pages.*'], now()->addDay());
// One request, one documented path — the URL a real caller hits and // One request, one documented path — the URL a real caller hits and
// the path key that describes it, side by side rather than derived // the path key that describes it, side by side rather than derived
@@ -155,6 +158,7 @@ class AdminApiContractTest extends TestCase
'media' => ['/v1/media', '/v1/media'], 'media' => ['/v1/media', '/v1/media'],
'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'], 'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'],
'revisions' => ["/v1/projects/{$project->id}/revisions", '/v1/projects/{project}/revisions'], 'revisions' => ["/v1/projects/{$project->id}/revisions", '/v1/projects/{project}/revisions'],
'tokens' => ['/v1/tokens', '/v1/tokens'],
'review thread' => ["/v1/pages/{$page->id}/reviews", '/v1/pages/{page}/reviews'], 'review thread' => ["/v1/pages/{$page->id}/reviews", '/v1/pages/{page}/reviews'],
'users' => ['/v1/users', '/v1/users'], 'users' => ['/v1/users', '/v1/users'],
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'], 'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\ApiToken;
use App\Models\Page;
use App\Models\Technology;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Machine tokens: made by a person, acting as them, never as more.
*/
class MachineTokenTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
protected function person(string $role): User
{
$user = User::factory()->create();
$user->syncRoles([$role]);
return $user;
}
/**
* @param array<int, string> $abilities
*/
protected function tokenFor(User $user, array $abilities, ?\DateTimeInterface $expires = null): string
{
[, $plain] = ApiToken::issue($user, 'CI', $abilities, $expires ?? now()->addDay());
$this->app['auth']->forgetGuards();
$this->withHeaders(['Authorization' => 'Bearer '.$plain, 'Accept' => 'application/json']);
return $plain;
}
#[Test]
public function a_person_makes_a_token_and_sees_it_only_once(): void
{
$this->withHeaders($this->apiHeadersFor($this->person('editor')));
$response = $this->postJson('/api/v1/tokens', [
'name' => 'Despliegue',
'abilities' => ['technologies.*', 'pages.view_any'],
])
->assertCreated()
->assertJsonPath('data.status', 'active')
->assertJsonPath('data.abilities', ['technologies.*', 'pages.view_any']);
$plain = $response->json('token');
$this->assertStringStartsWith(ApiToken::PREFIX, $plain);
$this->assertSame(ApiToken::hash($plain), ApiToken::query()->sole()->token_hash);
$this->getJson('/api/v1/tokens')
->assertJsonCount(1, 'data')
->assertJsonMissingPath('data.0.token');
}
#[Test]
public function a_token_cannot_be_given_what_its_maker_lacks(): void
{
$this->withHeaders($this->apiHeadersFor($this->person('writer')));
$this->postJson('/api/v1/tokens', ['name' => 'X', 'abilities' => ['users.update']])
->assertUnprocessable()
->assertJsonValidationErrors('abilities.0');
$this->postJson('/api/v1/tokens', ['name' => 'X', 'abilities' => ['nothing.*']])
->assertUnprocessable();
$this->postJson('/api/v1/tokens', [
'name' => 'X',
'abilities' => ['pages.view_any'],
'expires_at' => now()->addYears(2)->toIso8601String(),
])->assertUnprocessable()->assertJsonValidationErrors('expires_at');
}
#[Test]
public function a_token_acts_as_its_maker_within_its_abilities(): void
{
Http::fake();
$editor = $this->person('editor');
$this->tokenFor($editor, ['technologies.*']);
$this->postJson('/api/v1/technologies', ['name' => 'Vue', 'slug' => 'vue'])->assertCreated();
$this->getJson('/api/v1/pages')->assertForbidden();
$permissions = $this->getJson('/api/v1/me')->assertOk()->json('data.permissions');
$this->assertContains('technologies.create', $permissions);
$this->assertNotContains('pages.view_any', $permissions);
// Never sent to the identity server to be checked.
Http::assertNothingSent();
}
#[Test]
public function a_super_admin_token_is_still_limited(): void
{
$this->tokenFor($this->person('super_admin'), ['pages.view_any', 'pages.view']);
$page = Page::factory()->create();
$this->getJson('/api/v1/pages')->assertOk();
$this->getJson("/api/v1/pages/{$page->id}")->assertOk();
$this->deleteJson("/api/v1/pages/{$page->id}")->assertForbidden();
$this->postJson('/api/v1/technologies', ['name' => 'Vue', 'slug' => 'vue'])->assertForbidden();
}
#[Test]
public function losing_a_role_narrows_the_token_at_once(): void
{
$editor = $this->person('editor');
$this->tokenFor($editor, ['technologies.*']);
$editor->syncRoles(['viewer']);
$this->postJson('/api/v1/technologies', ['name' => 'Vue', 'slug' => 'vue'])->assertForbidden();
$this->assertSame(0, Technology::query()->count());
}
#[Test]
public function expired_revoked_or_deactivated_tokens_do_not_work(): void
{
$editor = $this->person('editor');
$this->tokenFor($editor, ['pages.*'], now()->subMinute());
$this->getJson('/api/v1/pages')->assertUnauthorized();
$plain = $this->tokenFor($editor, ['pages.*']);
ApiToken::query()->where('token_hash', ApiToken::hash($plain))->sole()->revoke();
$this->getJson('/api/v1/pages')->assertUnauthorized();
$this->tokenFor($editor, ['pages.*']);
$editor->update(['is_active' => false]);
$this->getJson('/api/v1/pages')->assertUnauthorized();
}
#[Test]
public function a_token_cannot_make_or_revoke_tokens(): void
{
$editor = $this->person('editor');
$this->tokenFor($editor, ['pages.*']);
$this->postJson('/api/v1/tokens', ['name' => 'Forever', 'abilities' => ['pages.*']])->assertForbidden();
$this->getJson('/api/v1/tokens')->assertForbidden();
}
#[Test]
public function a_revoked_token_stays_listed(): void
{
$editor = $this->person('editor');
[$token] = ApiToken::issue($editor, 'Old', ['pages.*'], now()->addDay());
$this->withHeaders($this->apiHeadersFor($editor));
$this->deleteJson("/api/v1/tokens/{$token->id}")->assertNoContent();
$this->getJson('/api/v1/tokens')->assertJsonPath('data.0.status', 'revoked');
// Someone else's is not theirs to revoke.
[$other] = ApiToken::issue($this->person('editor'), 'Theirs', ['pages.*'], now()->addDay());
$this->deleteJson("/api/v1/tokens/{$other->id}")->assertForbidden();
}
}