From f93775e549290460e40ad61b38d11ffe7ec18eed Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 1 Oct 2026 09:21:37 +0200 Subject: [PATCH] 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 --- README.md | 29 ++- app/Auth/PireonTokenGuard.php | 27 ++- .../Controllers/Api/V1/ApiTokenController.php | 104 ++++++++++ app/Http/Resources/ApiTokenResource.php | 40 ++++ app/Http/Resources/UserResource.php | 6 +- app/Models/ApiToken.php | 128 ++++++++++++ app/Models/User.php | 26 +++ app/Providers/AuthServiceProvider.php | 36 +++- config/permission.php | 5 +- ...6_10_01_090000_create_api_tokens_table.php | 44 +++++ lang/ca/rules.php | 5 + lang/en/rules.php | 5 + lang/es/rules.php | 5 + mcp/server.mjs | 3 +- public/openapi.yaml | 110 ++++++++++- routes/api.php | 6 + tests/Feature/Api/AdminApiContractTest.php | 4 + tests/Feature/Api/MachineTokenTest.php | 182 ++++++++++++++++++ 18 files changed, 748 insertions(+), 17 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/ApiTokenController.php create mode 100644 app/Http/Resources/ApiTokenResource.php create mode 100644 app/Models/ApiToken.php create mode 100644 database/migrations/2026_10_01_090000_create_api_tokens_table.php create mode 100644 tests/Feature/Api/MachineTokenTest.php diff --git a/README.md b/README.md index c8999a2..612ae0e 100644 --- a/README.md +++ b/README.md @@ -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. +### 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 ``` @@ -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_USERNAME credenciales de Pireon para la API de administración; 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_OPENAPI opcional: ruta o URL de la especificación ``` diff --git a/app/Auth/PireonTokenGuard.php b/app/Auth/PireonTokenGuard.php index 9eb2dfe..7945025 100644 --- a/app/Auth/PireonTokenGuard.php +++ b/app/Auth/PireonTokenGuard.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Auth; use App\Actions\Auth\SyncPireonUser; +use App\Models\ApiToken; use App\Models\User; use App\Services\Pireon\PireonClient; use App\Services\Pireon\PireonException; @@ -35,7 +36,16 @@ class PireonTokenGuard { $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; } @@ -52,6 +62,21 @@ class PireonTokenGuard 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 { $key = 'pireon:introspect:'.hash('sha256', $token); diff --git a/app/Http/Controllers/Api/V1/ApiTokenController.php b/app/Http/Controllers/Api/V1/ApiTokenController.php new file mode 100644 index 0000000..94e57f4 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ApiTokenController.php @@ -0,0 +1,104 @@ +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; + } +} diff --git a/app/Http/Resources/ApiTokenResource.php b/app/Http/Resources/ApiTokenResource.php new file mode 100644 index 0000000..0bb6938 --- /dev/null +++ b/app/Http/Resources/ApiTokenResource.php @@ -0,0 +1,40 @@ + + */ + 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(), + ]; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 6db3f9d..8b67216 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Resources; use App\Models\User; +use App\Support\Permissions; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -35,7 +36,10 @@ class UserResource extends JsonResource 'roles' => $this->whenLoaded('roles', fn () => $this->roles->pluck('name')->values()), 'permissions' => $this->when( $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 diff --git a/app/Models/ApiToken.php b/app/Models/ApiToken.php new file mode 100644 index 0000000..a38dc6b --- /dev/null +++ b/app/Models/ApiToken.php @@ -0,0 +1,128 @@ + 'array', + 'expires_at' => 'datetime', + 'last_used_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + 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 $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()); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index d75b66b..8d0dc07 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -42,6 +42,32 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasName */ 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 + */ + public function apiTokens(): HasMany + { + return $this->hasMany(ApiToken::class); + } + /** * Get the attributes that should be cast. * diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 655d8cf..34d8edf 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -37,6 +37,7 @@ use App\Policies\TalkPolicy; use App\Policies\TechnologyPolicy; use App\Policies\TestimonialPolicy; use App\Policies\UserPolicy; +use App\Support\Permissions; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; use Spatie\Permission\Models\Role; @@ -63,12 +64,35 @@ class AuthServiceProvider extends ServiceProvider Gate::policy(User::class, UserPolicy::class); Gate::policy(Role::class, RolePolicy::class); - // Super admins bypass every permission check. Granting the role this - // way rather than by attaching every permission means the role can - // never fall behind as the catalogue grows. + // The one place a permission is granted — spatie's own gate check is + // switched off (config/permission.php) so nothing answers before this. // - // Returning null (not false) when the role is absent is important: it - // lets the normal policy chain run instead of short-circuiting it. - Gate::before(fn (User $user, string $ability) => $user->hasRole('super_admin') ? true : null); + // Super admins bypass every check. Granting the role this way rather + // than by attaching every permission means the role can never fall + // 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; + }); } } diff --git a/config/permission.php b/config/permission.php index 85b8832..52eff15 100644 --- a/config/permission.php +++ b/config/permission.php @@ -105,7 +105,10 @@ return [ * 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 diff --git a/database/migrations/2026_10_01_090000_create_api_tokens_table.php b/database/migrations/2026_10_01_090000_create_api_tokens_table.php new file mode 100644 index 0000000..16ec51e --- /dev/null +++ b/database/migrations/2026_10_01_090000_create_api_tokens_table.php @@ -0,0 +1,44 @@ +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'); + } +}; diff --git a/lang/ca/rules.php b/lang/ca/rules.php index a8d6774..d5f7a5a 100644 --- a/lang/ca/rules.php +++ b/lang/ca/rules.php @@ -44,6 +44,11 @@ return [ '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' => [ 'read_only' => 'Aquest ajust l\'escriu l\'aplicació i no es pot editar a mà.', ], diff --git a/lang/en/rules.php b/lang/en/rules.php index bb0053d..450dfbc 100644 --- a/lang/en/rules.php +++ b/lang/en/rules.php @@ -44,6 +44,11 @@ return [ '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' => [ 'read_only' => 'This setting is written by the application and cannot be edited by hand.', ], diff --git a/lang/es/rules.php b/lang/es/rules.php index 41f37ce..eec4904 100644 --- a/lang/es/rules.php +++ b/lang/es/rules.php @@ -44,6 +44,11 @@ return [ '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' => [ 'read_only' => 'Este ajuste lo escribe la aplicación y no se puede editar a mano.', ], diff --git a/mcp/server.mjs b/mcp/server.mjs index 3b4464b..b7e83f3 100644 --- a/mcp/server.mjs +++ b/mcp/server.mjs @@ -20,7 +20,8 @@ * * LANDING_USERNAME Pireon credentials for the admin API (/v1). The * 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): * diff --git a/public/openapi.yaml b/public/openapi.yaml index 4eabf60..0146a98 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -16,9 +16,8 @@ info: ## Authentication Every endpoint under `/v1` except `POST /v1/auth/token` requires a bearer token issued - by the **Pireon identity server** (`https://oauth.pireon.dev`). This - application does not issue tokens of its own; it validates the ones the - identity server issued. + by the **Pireon identity server** (`https://oauth.pireon.dev`), which this + application validates — or a **machine token** of its own. 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, @@ -26,6 +25,13 @@ info: against `https://oauth.pireon.dev/oauth/authorize` remains available for 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 The identity server establishes *who* the caller is. What they may *do* is @@ -1029,6 +1035,71 @@ paths: data: { $ref: "#/components/schemas/User" } "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: get: tags: [Pages] @@ -4046,9 +4117,9 @@ components: type: http scheme: bearer description: | - An access token issued by https://oauth.pireon.dev. Validated on each - request against the identity server's `GET /api/user`, with the result - cached briefly. + An access token issued by https://oauth.pireon.dev, validated on each + request against the identity server's `GET /api/user` with the result + cached briefly — or a machine token (`pmt_…`) from `POST /v1/tokens`. ingestToken: type: apiKey @@ -5542,6 +5613,33 @@ components: failed: { type: integer } 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: type: string enum: [pages, technologies, projects, members, clients, testimonials] diff --git a/routes/api.php b/routes/api.php index 21fd0c7..331442b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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\SiteController; 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\BatchController; 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::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::post('pages/{page}/restore', [PageController::class, 'restore']) ->withTrashed() diff --git a/tests/Feature/Api/AdminApiContractTest.php b/tests/Feature/Api/AdminApiContractTest.php index 0b3917f..1f7c445 100644 --- a/tests/Feature/Api/AdminApiContractTest.php +++ b/tests/Feature/Api/AdminApiContractTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Feature\Api; +use App\Models\ApiToken; use App\Models\Application; use App\Models\Call; use App\Models\Category; @@ -92,6 +93,7 @@ class AdminApiContractTest extends TestCase 'missing-urls' => ['path' => '/v1/missing-urls'], 'repositories' => ['path' => '/v1/repositories'], 'media' => ['path' => '/v1/media'], + 'tokens' => ['path' => '/v1/tokens'], ]; } @@ -115,6 +117,7 @@ class AdminApiContractTest extends TestCase Setting::set(Maintenance::KEY, ['enabled' => false]); $media = Media::factory()->create(); $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 // 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'], 'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'], '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'], 'users' => ['/v1/users', '/v1/users'], 'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'], diff --git a/tests/Feature/Api/MachineTokenTest.php b/tests/Feature/Api/MachineTokenTest.php new file mode 100644 index 0000000..8c4a318 --- /dev/null +++ b/tests/Feature/Api/MachineTokenTest.php @@ -0,0 +1,182 @@ +seed(RolePermissionSeeder::class); + } + + protected function person(string $role): User + { + $user = User::factory()->create(); + $user->syncRoles([$role]); + + return $user; + } + + /** + * @param array $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(); + } +}