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

@@ -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]