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(); + } +}