feat(16.3-01): account token API with OpenAPI and phrase keys
- Add list/create/revoke/scopes handlers, validation, and soft revoke - Regenerate admin OpenAPI and TS types; inventory and conformance cases - Add server-side api_tokens phrase keys (en/pl) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,151 @@
|
|||||||
{
|
{
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"cabana.AdminAPIToken": {
|
||||||
|
"properties": {
|
||||||
|
"created_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expired": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"hint": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"revoked_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminAPITokenScope"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"created_at",
|
||||||
|
"expired",
|
||||||
|
"expires_at",
|
||||||
|
"hint",
|
||||||
|
"id",
|
||||||
|
"last_used_at",
|
||||||
|
"name",
|
||||||
|
"revoked_at",
|
||||||
|
"scopes"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"cabana.AdminAPITokenCreated": {
|
||||||
|
"properties": {
|
||||||
|
"created_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expired": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"hint": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"revoked_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminAPITokenScope"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"created_at",
|
||||||
|
"expired",
|
||||||
|
"expires_at",
|
||||||
|
"hint",
|
||||||
|
"id",
|
||||||
|
"last_used_at",
|
||||||
|
"name",
|
||||||
|
"revoked_at",
|
||||||
|
"scopes",
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"cabana.AdminAPITokenRequest": {
|
||||||
|
"properties": {
|
||||||
|
"expires": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"expires",
|
||||||
|
"name",
|
||||||
|
"scopes"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"cabana.AdminAPITokenScope": {
|
||||||
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"known": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"read_only": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"description",
|
||||||
|
"known",
|
||||||
|
"label",
|
||||||
|
"name",
|
||||||
|
"read_only"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.AdminAccountRequest": {
|
"cabana.AdminAccountRequest": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"current_password": {
|
"current_password": {
|
||||||
@@ -322,6 +467,42 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.Envelope-array_cabana_AdminAPIToken": {
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminAPIToken"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"data",
|
||||||
|
"meta"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"cabana.Envelope-array_cabana_AdminAPITokenScope": {
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminAPITokenScope"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"data",
|
||||||
|
"meta"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.Envelope-array_cabana_FileItem": {
|
"cabana.Envelope-array_cabana_FileItem": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -394,6 +575,36 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.Envelope-cabana_AdminAPIToken": {
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminAPIToken"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"data",
|
||||||
|
"meta"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"cabana.Envelope-cabana_AdminAPITokenCreated": {
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminAPITokenCreated"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"data",
|
||||||
|
"meta"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.Envelope-cabana_AdminActionResult": {
|
"cabana.Envelope-cabana_AdminActionResult": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -2176,7 +2387,7 @@
|
|||||||
},
|
},
|
||||||
"securitySchemes": {
|
"securitySchemes": {
|
||||||
"BackendBearer": {
|
"BackendBearer": {
|
||||||
"description": "Backend admin bearer token. Send \"Bearer {access_token}\".",
|
"description": "Backend admin access token or personal API token (g15_…). Send \"Bearer {token}\". Account and token management routes refuse personal API tokens.",
|
||||||
"in": "header",
|
"in": "header",
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
"type": "apiKey"
|
"type": "apiKey"
|
||||||
@@ -2193,7 +2404,7 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"/account": {
|
"/account": {
|
||||||
"put": {
|
"put": {
|
||||||
"description": "Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie.",
|
"description": "Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie. Personal API tokens are refused (403 interactive_only).",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@@ -2398,6 +2609,240 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/account/tokens": {
|
||||||
|
"get": {
|
||||||
|
"description": "Lists the signed-in admin's personal API tokens, newest first. Revoked tokens are hidden unless revoked=1. Personal API tokens are refused (403 interactive_only).",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Include revoked tokens when 1 or true",
|
||||||
|
"in": "query",
|
||||||
|
"name": "revoked",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.Envelope-array_cabana_AdminAPIToken"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BackendBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "List my API tokens",
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"description": "Creates a personal API token for the signed-in admin. The plaintext token is returned exactly once in the 201 body. Personal API tokens are refused (403 interactive_only).",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminAPITokenRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Name, expiry and scopes",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminAPITokenCreated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Created"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unprocessable Entity"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BackendBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Create an API token",
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/account/tokens/scopes": {
|
||||||
|
"get": {
|
||||||
|
"description": "Lists framework and plugin scopes the signed-in admin may pick when creating a token. Personal API tokens are refused (403 interactive_only).",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.Envelope-array_cabana_AdminAPITokenScope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BackendBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "List available API token scopes",
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/account/tokens/{id}": {
|
||||||
|
"delete": {
|
||||||
|
"description": "Soft-revokes one of the signed-in admin's active personal API tokens. Personal API tokens are refused (403 interactive_only).",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Token id",
|
||||||
|
"in": "path",
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminAPIToken"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Not Found"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BackendBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Revoke an API token",
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/auth/login": {
|
"/auth/login": {
|
||||||
"post": {
|
"post": {
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
|||||||
294
admin/src/api/schema.d.ts
vendored
294
admin/src/api/schema.d.ts
vendored
@@ -14,7 +14,7 @@ export interface paths {
|
|||||||
get?: never;
|
get?: never;
|
||||||
/**
|
/**
|
||||||
* Update my account
|
* Update my account
|
||||||
* @description Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie.
|
* @description Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie. Personal API tokens are refused (403 interactive_only).
|
||||||
*/
|
*/
|
||||||
put: {
|
put: {
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -210,6 +210,247 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/account/tokens": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List my API tokens
|
||||||
|
* @description Lists the signed-in admin's personal API tokens, newest first. Revoked tokens are hidden unless revoked=1. Personal API tokens are refused (403 interactive_only).
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
/** @description Include revoked tokens when 1 or true */
|
||||||
|
revoked?: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.Envelope-array_cabana_AdminAPIToken"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Create an API token
|
||||||
|
* @description Creates a personal API token for the signed-in admin. The plaintext token is returned exactly once in the 201 body. Personal API tokens are refused (403 interactive_only).
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** @description Name, expiry and scopes */
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.AdminAPITokenRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminAPITokenCreated"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unprocessable Entity */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/account/tokens/scopes": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List available API token scopes
|
||||||
|
* @description Lists framework and plugin scopes the signed-in admin may pick when creating a token. Personal API tokens are refused (403 interactive_only).
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.Envelope-array_cabana_AdminAPITokenScope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/account/tokens/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/**
|
||||||
|
* Revoke an API token
|
||||||
|
* @description Soft-revokes one of the signed-in admin's active personal API tokens. Personal API tokens are refused (403 interactive_only).
|
||||||
|
*/
|
||||||
|
delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Token id */
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminAPIToken"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/auth/login": {
|
"/auth/login": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4669,6 +4910,41 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
"cabana.AdminAPIToken": {
|
||||||
|
created_at: string;
|
||||||
|
expired: boolean;
|
||||||
|
expires_at: string;
|
||||||
|
hint: string;
|
||||||
|
id: number;
|
||||||
|
last_used_at: string;
|
||||||
|
name: string;
|
||||||
|
revoked_at: string;
|
||||||
|
scopes: components["schemas"]["cabana.AdminAPITokenScope"][];
|
||||||
|
};
|
||||||
|
"cabana.AdminAPITokenCreated": {
|
||||||
|
created_at: string;
|
||||||
|
expired: boolean;
|
||||||
|
expires_at: string;
|
||||||
|
hint: string;
|
||||||
|
id: number;
|
||||||
|
last_used_at: string;
|
||||||
|
name: string;
|
||||||
|
revoked_at: string;
|
||||||
|
scopes: components["schemas"]["cabana.AdminAPITokenScope"][];
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
"cabana.AdminAPITokenRequest": {
|
||||||
|
expires: string;
|
||||||
|
name: string;
|
||||||
|
scopes: string[];
|
||||||
|
};
|
||||||
|
"cabana.AdminAPITokenScope": {
|
||||||
|
description: string;
|
||||||
|
known: boolean;
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
read_only: boolean;
|
||||||
|
};
|
||||||
"cabana.AdminAccountRequest": {
|
"cabana.AdminAccountRequest": {
|
||||||
current_password?: string;
|
current_password?: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -4774,6 +5050,14 @@ export interface components {
|
|||||||
scripts: string[];
|
scripts: string[];
|
||||||
styles: string[];
|
styles: string[];
|
||||||
};
|
};
|
||||||
|
"cabana.Envelope-array_cabana_AdminAPIToken": {
|
||||||
|
data: components["schemas"]["cabana.AdminAPIToken"][];
|
||||||
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
};
|
||||||
|
"cabana.Envelope-array_cabana_AdminAPITokenScope": {
|
||||||
|
data: components["schemas"]["cabana.AdminAPITokenScope"][];
|
||||||
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
};
|
||||||
"cabana.Envelope-array_cabana_FileItem": {
|
"cabana.Envelope-array_cabana_FileItem": {
|
||||||
data: components["schemas"]["cabana.FileItem"][];
|
data: components["schemas"]["cabana.FileItem"][];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
@@ -4790,6 +5074,14 @@ export interface components {
|
|||||||
data: components["schemas"]["cabana.SettingsEntry"][];
|
data: components["schemas"]["cabana.SettingsEntry"][];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
};
|
};
|
||||||
|
"cabana.Envelope-cabana_AdminAPIToken": {
|
||||||
|
data: components["schemas"]["cabana.AdminAPIToken"];
|
||||||
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
};
|
||||||
|
"cabana.Envelope-cabana_AdminAPITokenCreated": {
|
||||||
|
data: components["schemas"]["cabana.AdminAPITokenCreated"];
|
||||||
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
};
|
||||||
"cabana.Envelope-cabana_AdminActionResult": {
|
"cabana.Envelope-cabana_AdminActionResult": {
|
||||||
data: components["schemas"]["cabana.AdminActionResult"];
|
data: components["schemas"]["cabana.AdminActionResult"];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
|||||||
306
modules/cabana/account_tokens.go
Normal file
306
modules/cabana/account_tokens.go
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminAPITokenScope is one scope on a token or in the picker list.
|
||||||
|
type AdminAPITokenScope struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ReadOnly bool `json:"read_only"`
|
||||||
|
Known bool `json:"known"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAPIToken is one personal API token row in list/revoke responses.
|
||||||
|
type AdminAPIToken struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Hint string `json:"hint"`
|
||||||
|
Scopes []AdminAPITokenScope `json:"scopes"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
RevokedAt *time.Time `json:"revoked_at"`
|
||||||
|
Expired bool `json:"expired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAPITokenCreated is the create response: every AdminAPIToken field plus
|
||||||
|
// the plaintext token (shown exactly once).
|
||||||
|
type AdminAPITokenCreated struct {
|
||||||
|
AdminAPIToken
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAPITokenRequest is the POST /account/tokens body.
|
||||||
|
type AdminAPITokenRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Expires string `json:"expires"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowedTokenCreateKeys = map[string]bool{"name": true, "expires": true, "scopes": true}
|
||||||
|
|
||||||
|
func (s *service) accountTokensList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal := accountPrincipal(w, r)
|
||||||
|
if principal == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, err := s.db()
|
||||||
|
if err != nil {
|
||||||
|
slog.Default().ErrorContext(r.Context(), "cabana: account tokens list without a database", "error", err)
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showRevoked := false
|
||||||
|
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("revoked"))) {
|
||||||
|
case "1", "true":
|
||||||
|
showRevoked = true
|
||||||
|
}
|
||||||
|
q := db.WithContext(r.Context()).Where("backend_user_id = ?", principal.ID)
|
||||||
|
if !showRevoked {
|
||||||
|
q = q.Where("revoked_at IS NULL")
|
||||||
|
}
|
||||||
|
var rows []BackendAPIToken
|
||||||
|
if err := q.Order("created_at DESC").Order("id DESC").Find(&rows).Error; err != nil {
|
||||||
|
slog.Default().ErrorContext(r.Context(), "cabana: account tokens list", "error", err)
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tr := s.translator()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
out := make([]AdminAPIToken, 0, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
out = append(out, mapAdminAPIToken(r.Context(), tr, s.reg, &rows[i], now))
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusOK, out, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) accountTokenCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal := accountPrincipal(w, r)
|
||||||
|
if principal == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := s.decodeCappedObject(w, r)
|
||||||
|
if err != nil {
|
||||||
|
writeRelationError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tr := s.translator()
|
||||||
|
if details := refusedTokenCreateKeys(r.Context(), tr, body); len(details) > 0 {
|
||||||
|
writeCRUDError(w, &ValidationError{Details: details})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, _ := body["name"].(string)
|
||||||
|
expires, _ := body["expires"].(string)
|
||||||
|
var scopes []string
|
||||||
|
switch v := body["scopes"].(type) {
|
||||||
|
case []any:
|
||||||
|
for _, item := range v {
|
||||||
|
if s, ok := item.(string); ok {
|
||||||
|
scopes = append(scopes, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []string:
|
||||||
|
scopes = v
|
||||||
|
}
|
||||||
|
db, err := s.db()
|
||||||
|
if err != nil {
|
||||||
|
slog.Default().ErrorContext(r.Context(), "cabana: account token create without a database", "error", err)
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
plain, row, details, err := issueAPIToken(db, s.reg, principal, name, expires, scopes, now, false)
|
||||||
|
if err != nil {
|
||||||
|
slog.Default().ErrorContext(r.Context(), "cabana: account token create", "error", err)
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if details != nil {
|
||||||
|
writeCRUDError(w, &ValidationError{Details: translateTokenDetails(r.Context(), tr, details)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created := AdminAPITokenCreated{
|
||||||
|
AdminAPIToken: mapAdminAPIToken(r.Context(), tr, s.reg, row, now),
|
||||||
|
Token: plain,
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusCreated, created, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) accountTokenRevoke(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal := accountPrincipal(w, r)
|
||||||
|
if principal == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, err := s.db()
|
||||||
|
if err != nil {
|
||||||
|
slog.Default().ErrorContext(r.Context(), "cabana: account token revoke without a database", "error", err)
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
revokedBy := principal.ID
|
||||||
|
row, notFound, err := revokeAPIToken(db, principal.ID, uint(id), &revokedBy)
|
||||||
|
if err != nil {
|
||||||
|
slog.Default().ErrorContext(r.Context(), "cabana: account token revoke", "error", err)
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if notFound {
|
||||||
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusOK, mapAdminAPIToken(r.Context(), s.translator(), s.reg, row, time.Now().UTC()), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) accountTokenScopes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal := accountPrincipal(w, r)
|
||||||
|
if principal == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tr := s.translator()
|
||||||
|
var out []AdminAPITokenScope
|
||||||
|
if s.reg != nil && s.reg.tokenScopes != nil {
|
||||||
|
for _, scope := range s.reg.tokenScopes.ordered {
|
||||||
|
if !scopeOverlaps(s.reg, principal, scope.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, mapCompiledScope(r.Context(), tr, scope))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusOK, out, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refusedTokenCreateKeys(ctx context.Context, tr *phrasebook.Translator, body map[string]any) map[string]any {
|
||||||
|
details := map[string]any{}
|
||||||
|
for key := range body {
|
||||||
|
if !allowedTokenCreateKeys[key] {
|
||||||
|
details[key] = []string{backendMessage(ctx, tr, "myaccount.field_not_editable", "This field cannot be changed.")}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
func translateTokenDetails(ctx context.Context, tr *phrasebook.Translator, details map[string]any) map[string]any {
|
||||||
|
out := map[string]any{}
|
||||||
|
for field, raw := range details {
|
||||||
|
keys, _ := raw.([]string)
|
||||||
|
msgs := make([]string, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
if name, ok := strings.CutPrefix(key, "api_tokens.scope_unavailable|"); ok {
|
||||||
|
msgs = append(msgs, backendMessageParams(ctx, tr, "api_tokens.scope_unavailable", "The scope :name is not available to you.", map[string]string{"name": name}))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "api_tokens.name_required":
|
||||||
|
msgs = append(msgs, backendMessage(ctx, tr, key, "Enter a name for the token."))
|
||||||
|
case "api_tokens.name_too_long":
|
||||||
|
msgs = append(msgs, backendMessage(ctx, tr, key, "The name may have at most 100 characters."))
|
||||||
|
case "api_tokens.expires_invalid":
|
||||||
|
msgs = append(msgs, backendMessage(ctx, tr, key, "Choose a valid expiry."))
|
||||||
|
case "api_tokens.scopes_required":
|
||||||
|
msgs = append(msgs, backendMessage(ctx, tr, key, "Choose at least one scope."))
|
||||||
|
default:
|
||||||
|
msgs = append(msgs, backendMessage(ctx, tr, key, key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[field] = msgs
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func backendMessageParams(ctx context.Context, tr *phrasebook.Translator, key, fallback string, params map[string]string) string {
|
||||||
|
full := "backend::lang." + key
|
||||||
|
if tr != nil && tr.Has(full) {
|
||||||
|
if s := tr.Get(ctx, full, params); s != "" && s != full {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg := fallback
|
||||||
|
for k, v := range params {
|
||||||
|
msg = strings.ReplaceAll(msg, ":"+k, v)
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapAdminAPIToken(ctx context.Context, tr *phrasebook.Translator, reg *Registry, row *BackendAPIToken, now time.Time) AdminAPIToken {
|
||||||
|
stored := []string{}
|
||||||
|
if row.Scopes.Valid {
|
||||||
|
stored = row.Scopes.Get()
|
||||||
|
}
|
||||||
|
scopes := make([]AdminAPITokenScope, 0, len(stored))
|
||||||
|
for _, name := range stored {
|
||||||
|
scopes = append(scopes, mapStoredScope(ctx, tr, reg, name))
|
||||||
|
}
|
||||||
|
expired := row.ExpiresAt != nil && !row.ExpiresAt.After(now) && row.RevokedAt == nil
|
||||||
|
return AdminAPIToken{
|
||||||
|
ID: row.ID,
|
||||||
|
Name: row.Name,
|
||||||
|
Hint: row.TokenHint,
|
||||||
|
Scopes: scopes,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
LastUsedAt: row.LastUsedAt,
|
||||||
|
ExpiresAt: row.ExpiresAt,
|
||||||
|
RevokedAt: row.RevokedAt,
|
||||||
|
Expired: expired,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapStoredScope(ctx context.Context, tr *phrasebook.Translator, reg *Registry, name string) AdminAPITokenScope {
|
||||||
|
if reg != nil && reg.tokenScopes != nil {
|
||||||
|
if scope, ok := reg.tokenScopes.byName[name]; ok {
|
||||||
|
return mapCompiledScope(ctx, tr, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AdminAPITokenScope{Name: name, Label: name, Description: "", ReadOnly: false, Known: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapCompiledScope(ctx context.Context, tr *phrasebook.Translator, scope *compiledTokenScope) AdminAPITokenScope {
|
||||||
|
label := scope.Label
|
||||||
|
desc := scope.Description
|
||||||
|
if strings.Contains(label, "::") {
|
||||||
|
label = resolveScopePhrase(ctx, tr, label, label)
|
||||||
|
}
|
||||||
|
if strings.Contains(desc, "::") {
|
||||||
|
desc = resolveScopePhrase(ctx, tr, desc, desc)
|
||||||
|
}
|
||||||
|
readOnly := true
|
||||||
|
for m := range scope.Methods {
|
||||||
|
if m != "GET" && m != "HEAD" {
|
||||||
|
readOnly = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scope.Full && scope.Name == scopeAll {
|
||||||
|
readOnly = false
|
||||||
|
}
|
||||||
|
return AdminAPITokenScope{
|
||||||
|
Name: scope.Name,
|
||||||
|
Label: label,
|
||||||
|
Description: desc,
|
||||||
|
ReadOnly: readOnly,
|
||||||
|
Known: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveScopePhrase(ctx context.Context, tr *phrasebook.Translator, key, fallback string) string {
|
||||||
|
if tr != nil && tr.Has(key) {
|
||||||
|
if s := tr.Get(ctx, key, nil); s != "" && s != key {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ package cabana
|
|||||||
// @securityDefinitions.apikey BackendBearer
|
// @securityDefinitions.apikey BackendBearer
|
||||||
// @in header
|
// @in header
|
||||||
// @name Authorization
|
// @name Authorization
|
||||||
// @description Backend admin bearer token. Send "Bearer {access_token}".
|
// @description Backend admin access token or personal API token (g15_…). Send "Bearer {token}". Account and token management routes refuse personal API tokens.
|
||||||
|
|
||||||
// Admin API annotations. scripts/check-admin-openapi.sh reads them with swag
|
// Admin API annotations. scripts/check-admin-openapi.sh reads them with swag
|
||||||
// to produce admin/openapi/admin.json, the document the SPA's TypeScript types
|
// to produce admin/openapi/admin.json, the document the SPA's TypeScript types
|
||||||
@@ -204,7 +204,7 @@ func AdminMe() {}
|
|||||||
// AdminAccountUpdate documents PUT /account.
|
// AdminAccountUpdate documents PUT /account.
|
||||||
//
|
//
|
||||||
// @Summary Update my account
|
// @Summary Update my account
|
||||||
// @Description Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie.
|
// @Description Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie. Personal API tokens are refused (403 interactive_only).
|
||||||
// @Tags admin
|
// @Tags admin
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@@ -218,6 +218,64 @@ func AdminMe() {}
|
|||||||
// @Router /account [put]
|
// @Router /account [put]
|
||||||
func AdminAccountUpdate() {}
|
func AdminAccountUpdate() {}
|
||||||
|
|
||||||
|
// AdminAccountTokens documents GET /account/tokens.
|
||||||
|
//
|
||||||
|
// @Summary List my API tokens
|
||||||
|
// @Description Lists the signed-in admin's personal API tokens, newest first. Revoked tokens are hidden unless revoked=1. Personal API tokens are refused (403 interactive_only).
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Security BackendBearer
|
||||||
|
// @Param revoked query string false "Include revoked tokens when 1 or true"
|
||||||
|
// @Success 200 {object} Envelope[[]AdminAPIToken]
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Router /account/tokens [get]
|
||||||
|
func AdminAccountTokens() {}
|
||||||
|
|
||||||
|
// AdminAccountTokenCreate documents POST /account/tokens.
|
||||||
|
//
|
||||||
|
// @Summary Create an API token
|
||||||
|
// @Description Creates a personal API token for the signed-in admin. The plaintext token is returned exactly once in the 201 body. Personal API tokens are refused (403 interactive_only).
|
||||||
|
// @Tags admin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BackendBearer
|
||||||
|
// @Param body body AdminAPITokenRequest true "Name, expiry and scopes"
|
||||||
|
// @Success 201 {object} Envelope[AdminAPITokenCreated]
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 422 {object} ErrorEnvelope
|
||||||
|
// @Router /account/tokens [post]
|
||||||
|
func AdminAccountTokenCreate() {}
|
||||||
|
|
||||||
|
// AdminAccountTokenRevoke documents DELETE /account/tokens/{id}.
|
||||||
|
//
|
||||||
|
// @Summary Revoke an API token
|
||||||
|
// @Description Soft-revokes one of the signed-in admin's active personal API tokens. Personal API tokens are refused (403 interactive_only).
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Security BackendBearer
|
||||||
|
// @Param id path int true "Token id"
|
||||||
|
// @Success 200 {object} Envelope[AdminAPIToken]
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
|
// @Router /account/tokens/{id} [delete]
|
||||||
|
func AdminAccountTokenRevoke() {}
|
||||||
|
|
||||||
|
// AdminAccountTokenScopes documents GET /account/tokens/scopes.
|
||||||
|
//
|
||||||
|
// @Summary List available API token scopes
|
||||||
|
// @Description Lists framework and plugin scopes the signed-in admin may pick when creating a token. Personal API tokens are refused (403 interactive_only).
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Security BackendBearer
|
||||||
|
// @Success 200 {object} Envelope[[]AdminAPITokenScope]
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Router /account/tokens/scopes [get]
|
||||||
|
func AdminAccountTokenScopes() {}
|
||||||
|
|
||||||
// AdminAccountAvatarUpload documents POST /account/avatar.
|
// AdminAccountAvatarUpload documents POST /account/avatar.
|
||||||
//
|
//
|
||||||
// @Summary Upload my avatar
|
// @Summary Upload my avatar
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -67,10 +68,119 @@ func wellFormedAPIToken(v string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// issueAPIToken mints a token for ownerID with the given scopes and optional
|
// cleanedTokenRequest is the validated create payload.
|
||||||
// expiry, inserts the row, and returns the plaintext exactly once. Task 3
|
type cleanedTokenRequest struct {
|
||||||
// grows this into the shared API/CLI validation path.
|
Name string
|
||||||
func issueAPIToken(db *gorm.DB, ownerID uint, name string, scopes []string, expiresAt *time.Time) (plain string, row *BackendAPIToken, err error) {
|
ExpiresAt *time.Time
|
||||||
|
Scopes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateTokenRequest applies D-08/D-17 name, expiry and scope rules with no
|
||||||
|
// database access. details maps field names to message keys under
|
||||||
|
// backend::lang (for example api_tokens.name_required). cli enables YYYY-MM-DD
|
||||||
|
// expiry dates (00:00 UTC, must be after now).
|
||||||
|
func validateTokenRequest(reg *Registry, owner *bouncer.Principal, name, expires string, scopes []string, now time.Time, cli bool) (cleanedTokenRequest, map[string]any) {
|
||||||
|
details := map[string]any{}
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
details["name"] = []string{"api_tokens.name_required"}
|
||||||
|
} else if len([]rune(name)) > 100 {
|
||||||
|
details["name"] = []string{"api_tokens.name_too_long"}
|
||||||
|
}
|
||||||
|
expiresAt, expKey := parseTokenExpiry(expires, now, cli)
|
||||||
|
if expKey != "" {
|
||||||
|
details["expires"] = []string{expKey}
|
||||||
|
}
|
||||||
|
cleanedScopes, scopeDetails := validateTokenScopes(reg, owner, scopes)
|
||||||
|
for k, v := range scopeDetails {
|
||||||
|
details[k] = v
|
||||||
|
}
|
||||||
|
if len(details) > 0 {
|
||||||
|
return cleanedTokenRequest{}, details
|
||||||
|
}
|
||||||
|
return cleanedTokenRequest{Name: name, ExpiresAt: expiresAt, Scopes: cleanedScopes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTokenExpiry(expires string, now time.Time, cli bool) (*time.Time, string) {
|
||||||
|
expires = strings.TrimSpace(expires)
|
||||||
|
if expires == "" {
|
||||||
|
expires = "90d"
|
||||||
|
}
|
||||||
|
switch expires {
|
||||||
|
case "30d":
|
||||||
|
t := now.UTC().Add(30 * 24 * time.Hour)
|
||||||
|
return &t, ""
|
||||||
|
case "90d":
|
||||||
|
t := now.UTC().Add(90 * 24 * time.Hour)
|
||||||
|
return &t, ""
|
||||||
|
case "365d":
|
||||||
|
t := now.UTC().Add(365 * 24 * time.Hour)
|
||||||
|
return &t, ""
|
||||||
|
case "never":
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
if cli {
|
||||||
|
if t, err := time.Parse("2006-01-02", expires); err == nil {
|
||||||
|
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
if !t.After(now.UTC()) {
|
||||||
|
return nil, "api_tokens.expires_invalid"
|
||||||
|
}
|
||||||
|
return &t, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, "api_tokens.expires_invalid"
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTokenScopes(reg *Registry, owner *bouncer.Principal, scopes []string) ([]string, map[string]any) {
|
||||||
|
details := map[string]any{}
|
||||||
|
if len(scopes) == 0 {
|
||||||
|
details["scopes"] = []string{"api_tokens.scopes_required"}
|
||||||
|
return nil, details
|
||||||
|
}
|
||||||
|
for _, s := range scopes {
|
||||||
|
if s == scopeAll {
|
||||||
|
return []string{scopeAll}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := make([]string, 0, len(scopes))
|
||||||
|
for _, name := range scopes {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" || seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
if reg == nil || reg.tokenScopes == nil || reg.tokenScopes.byName[name] == nil || !scopeOverlaps(reg, owner, name) {
|
||||||
|
details["scopes"] = []string{"api_tokens.scope_unavailable|" + name}
|
||||||
|
return nil, details
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
details["scopes"] = []string{"api_tokens.scopes_required"}
|
||||||
|
return nil, details
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueAPIToken validates and inserts a token for the owner. On validation
|
||||||
|
// failure details is non-nil and plain/row are zero. The plaintext is returned
|
||||||
|
// exactly once on success.
|
||||||
|
func issueAPIToken(db *gorm.DB, reg *Registry, owner *bouncer.Principal, name, expires string, scopes []string, now time.Time, cli bool) (plain string, row *BackendAPIToken, details map[string]any, err error) {
|
||||||
|
if owner == nil || owner.ID == 0 {
|
||||||
|
return "", nil, nil, fmt.Errorf("cabana: issue API token: missing owner")
|
||||||
|
}
|
||||||
|
cleaned, details := validateTokenRequest(reg, owner, name, expires, scopes, now, cli)
|
||||||
|
if details != nil {
|
||||||
|
return "", nil, details, nil
|
||||||
|
}
|
||||||
|
plain, row, err = insertAPIToken(db, owner.ID, cleaned.Name, cleaned.Scopes, cleaned.ExpiresAt)
|
||||||
|
return plain, row, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertAPIToken mints and persists a row without validation (tests and the
|
||||||
|
// validated issueAPIToken path).
|
||||||
|
func insertAPIToken(db *gorm.DB, ownerID uint, name string, scopes []string, expiresAt *time.Time) (plain string, row *BackendAPIToken, err error) {
|
||||||
if db == nil || ownerID == 0 {
|
if db == nil || ownerID == 0 {
|
||||||
return "", nil, fmt.Errorf("cabana: issue API token: missing database or owner")
|
return "", nil, fmt.Errorf("cabana: issue API token: missing database or owner")
|
||||||
}
|
}
|
||||||
@@ -98,6 +208,29 @@ func issueAPIToken(db *gorm.DB, ownerID uint, name string, scopes []string, expi
|
|||||||
return plain, row, nil
|
return plain, row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// revokeAPIToken soft-revokes the owner's active token. notFound is true when
|
||||||
|
// no matching active row was updated.
|
||||||
|
func revokeAPIToken(db *gorm.DB, ownerID, id uint, revokedBy *uint) (row *BackendAPIToken, notFound bool, err error) {
|
||||||
|
if db == nil || ownerID == 0 || id == 0 {
|
||||||
|
return nil, true, nil
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
res := db.Model(&BackendAPIToken{}).
|
||||||
|
Where("id = ? AND backend_user_id = ? AND revoked_at IS NULL", id, ownerID).
|
||||||
|
Updates(map[string]any{"revoked_at": now, "revoked_by": revokedBy})
|
||||||
|
if res.Error != nil {
|
||||||
|
return nil, false, res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return nil, true, nil
|
||||||
|
}
|
||||||
|
var out BackendAPIToken
|
||||||
|
if err := db.First(&out, id).Error; err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return &out, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
// tokenStore looks up usable personal API tokens by hash and stamps last_used_at.
|
// tokenStore looks up usable personal API tokens by hash and stamps last_used_at.
|
||||||
type tokenStore interface {
|
type tokenStore interface {
|
||||||
FindUsableByHash(ctx context.Context, hash string, now time.Time) (*BackendAPIToken, error)
|
FindUsableByHash(ctx context.Context, hash string, now time.Time) (*BackendAPIToken, error)
|
||||||
|
|||||||
@@ -22,6 +22,6 @@ func AdminAPIRoutes() []string {
|
|||||||
// scopes and optional expiry. Used by external Postgres smoke tests before
|
// scopes and optional expiry. Used by external Postgres smoke tests before
|
||||||
// the HTTP create route exists.
|
// the HTTP create route exists.
|
||||||
func IssueTestAPIToken(db *gorm.DB, ownerID uint, scopes []string, expiresAt *time.Time) (plain string, err error) {
|
func IssueTestAPIToken(db *gorm.DB, ownerID uint, scopes []string, expiresAt *time.Time) (plain string, err error) {
|
||||||
plain, _, err = issueAPIToken(db, ownerID, "test", scopes, expiresAt)
|
plain, _, err = insertAPIToken(db, ownerID, "test", scopes, expiresAt)
|
||||||
return plain, err
|
return plain, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,10 @@ func (s *service) mount(r pact.Router) {
|
|||||||
g.Put("/account", interactiveOnly(requireAjax(s.accountUpdate)))
|
g.Put("/account", interactiveOnly(requireAjax(s.accountUpdate)))
|
||||||
g.Post("/account/avatar", interactiveOnly(requireAjax(s.avatarUpload)))
|
g.Post("/account/avatar", interactiveOnly(requireAjax(s.avatarUpload)))
|
||||||
g.Delete("/account/avatar", interactiveOnly(requireAjax(s.avatarRemove)))
|
g.Delete("/account/avatar", interactiveOnly(requireAjax(s.avatarRemove)))
|
||||||
|
g.Get("/account/tokens", interactiveOnly(s.accountTokensList))
|
||||||
|
g.Post("/account/tokens", interactiveOnly(requireAjax(s.accountTokenCreate)))
|
||||||
|
g.Get("/account/tokens/scopes", interactiveOnly(s.accountTokenScopes))
|
||||||
|
g.Delete("/account/tokens/{id}", interactiveOnly(requireAjax(s.accountTokenRevoke)))
|
||||||
g.Get("/navigation", s.navigation)
|
g.Get("/navigation", s.navigation)
|
||||||
// Plugin field types (pact.HasFormFieldTypes): the SPA loads their
|
// Plugin field types (pact.HasFormFieldTypes): the SPA loads their
|
||||||
// scripts and styles once after sign-in.
|
// scripts and styles once after sign-in.
|
||||||
|
|||||||
@@ -540,6 +540,27 @@ func conformCases() []conformCase {
|
|||||||
}
|
}
|
||||||
return rec
|
return rec
|
||||||
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
|
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
|
||||||
|
{"POST /account/tokens", 201, "cabana.Envelope-cabana_AdminAPITokenCreated", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||||
|
rec := e.send(t, http.MethodPost, "/account/tokens", map[string]any{"name": "conform", "expires": "90d", "scopes": []string{"*"}}, true)
|
||||||
|
var body cabana.Envelope[cabana.AdminAPITokenCreated]
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.Token == "" || body.Data.ID == 0 {
|
||||||
|
t.Fatalf("token create body = %s (%v)", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
e.tokenID = body.Data.ID
|
||||||
|
return rec
|
||||||
|
}, into[cabana.Envelope[cabana.AdminAPITokenCreated]](), nil},
|
||||||
|
{"GET /account/tokens", 200, "cabana.Envelope-array_cabana_AdminAPIToken", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||||
|
return e.send(t, http.MethodGet, "/account/tokens", nil, true)
|
||||||
|
}, into[cabana.Envelope[[]cabana.AdminAPIToken]](), nil},
|
||||||
|
{"GET /account/tokens/scopes", 200, "cabana.Envelope-array_cabana_AdminAPITokenScope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||||
|
return e.send(t, http.MethodGet, "/account/tokens/scopes", nil, true)
|
||||||
|
}, into[cabana.Envelope[[]cabana.AdminAPITokenScope]](), nil},
|
||||||
|
{"DELETE /account/tokens/{id}", 200, "cabana.Envelope-cabana_AdminAPIToken", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||||
|
if e.tokenID == 0 {
|
||||||
|
t.Fatal("token id missing from create case")
|
||||||
|
}
|
||||||
|
return e.send(t, http.MethodDelete, fmt.Sprintf("/account/tokens/%d", e.tokenID), nil, true)
|
||||||
|
}, into[cabana.Envelope[cabana.AdminAPIToken]](), nil},
|
||||||
{"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
{"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||||
return e.send(t, http.MethodPost, "/auth/logout", nil, true)
|
return e.send(t, http.MethodPost, "/auth/logout", nil, true)
|
||||||
}, into[cabana.Envelope[cabana.AdminLogoutData]](), nil},
|
}, into[cabana.Envelope[cabana.AdminLogoutData]](), nil},
|
||||||
@@ -603,6 +624,7 @@ type conformEnv struct {
|
|||||||
partID uint
|
partID uint
|
||||||
partFileID uint
|
partFileID uint
|
||||||
childKey string
|
childKey string
|
||||||
|
tokenID uint
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendWith sends a raw body with extra headers through the assembled router.
|
// sendWith sends a raw body with extra headers through the assembled router.
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ var phase09Routes = []adminRoute{
|
|||||||
{key: "PUT /account"},
|
{key: "PUT /account"},
|
||||||
{key: "POST /account/avatar"},
|
{key: "POST /account/avatar"},
|
||||||
{key: "DELETE /account/avatar"},
|
{key: "DELETE /account/avatar"},
|
||||||
|
{key: "GET /account/tokens"},
|
||||||
|
{key: "POST /account/tokens"},
|
||||||
|
{key: "GET /account/tokens/scopes"},
|
||||||
|
{key: "DELETE /account/tokens/{id}"},
|
||||||
{key: "GET /navigation"},
|
{key: "GET /navigation"},
|
||||||
{key: "GET /field-types"},
|
{key: "GET /field-types"},
|
||||||
{key: "GET /settings"},
|
{key: "GET /settings"},
|
||||||
|
|||||||
@@ -4,18 +4,20 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"git.golem15.com/golem15/summercms/modules/party"
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
"git.golem15.com/golem15/summercms/modules/surf"
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
"git.golem15.com/golem15/summercms/modules/compass"
|
|
||||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type tokenDemoWidget struct {
|
type tokenDemoWidget struct {
|
||||||
@@ -154,3 +156,116 @@ func TestTokenTracerPostgres(t *testing.T) {
|
|||||||
t.Fatalf("soft-deleted owner: want 401, got %d %s", rec.Code, rec.Body.String())
|
t.Fatalf("soft-deleted owner: want 401, got %d %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAccountTokensSmoke(t *testing.T) {
|
||||||
|
gdb := adminGorm(t)
|
||||||
|
if err := gdb.AutoMigrate(&tokenDemoWidget{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := tokenAdminHandler(t)
|
||||||
|
user := insertAdmin(t, gdb, "tok-acct", "tok-acct@example.test", "secret-pass", true, false)
|
||||||
|
login := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||||
|
"login": user.Login, "password": "secret-pass",
|
||||||
|
})
|
||||||
|
if login.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login: %d %s", login.Code, login.Body.String())
|
||||||
|
}
|
||||||
|
jwt := accessToken(t, login.Body.Bytes())
|
||||||
|
|
||||||
|
scopesRec := getAuth(t, h, adminAPI("/account/tokens/scopes"), jwt)
|
||||||
|
if scopesRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("scopes: %d %s", scopesRec.Code, scopesRec.Body.String())
|
||||||
|
}
|
||||||
|
var scopesBody struct {
|
||||||
|
Data []cabana.AdminAPITokenScope `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(scopesRec.Body.Bytes(), &scopesBody); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, s := range scopesBody.Data {
|
||||||
|
names[s.Name] = true
|
||||||
|
}
|
||||||
|
if !names["*"] || !names["demo"] {
|
||||||
|
t.Fatalf("scopes = %#v, want * and demo", names)
|
||||||
|
}
|
||||||
|
|
||||||
|
create := postAuth(t, h, http.MethodPost, adminAPI("/account/tokens"), jwt, mustJSON(t, map[string]any{
|
||||||
|
"name": "agent", "expires": "90d", "scopes": []string{"demo"},
|
||||||
|
}))
|
||||||
|
if create.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d %s", create.Code, create.Body.String())
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
Data cabana.AdminAPITokenCreated `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !wellFormedPlain(created.Data.Token) {
|
||||||
|
t.Fatalf("token shape %q", created.Data.Token)
|
||||||
|
}
|
||||||
|
if strings.Contains(create.Body.String(), "token_hash") {
|
||||||
|
t.Fatal("create body leaked token_hash")
|
||||||
|
}
|
||||||
|
plain := created.Data.Token
|
||||||
|
hint := created.Data.Hint
|
||||||
|
if hint != plain[len(plain)-4:] {
|
||||||
|
t.Fatalf("hint %q != last4 of %q", hint, plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := getAuth(t, h, adminAPI("/account/tokens"), jwt)
|
||||||
|
if list.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list: %d %s", list.Code, list.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(list.Body.String(), `"token":`) {
|
||||||
|
t.Fatal("list body leaked plaintext token")
|
||||||
|
}
|
||||||
|
var listed struct {
|
||||||
|
Data []cabana.AdminAPIToken `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(list.Body.Bytes(), &listed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(listed.Data) == 0 || listed.Data[0].Hint != hint {
|
||||||
|
t.Fatalf("list = %#v", listed.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec := getAuth(t, h, adminAPI("/account/tokens"), plain); rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("token on token route: want 403, got %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
path := adminAPI("/account/tokens/" + strconv.FormatUint(uint64(created.Data.ID), 10))
|
||||||
|
del := postAuth(t, h, http.MethodDelete, path, jwt, nil)
|
||||||
|
if del.Code != http.StatusOK {
|
||||||
|
t.Fatalf("revoke: %d %s", del.Code, del.Body.String())
|
||||||
|
}
|
||||||
|
del2 := postAuth(t, h, http.MethodDelete, path, jwt, nil)
|
||||||
|
if del2.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("second revoke: want 404, got %d", del2.Code)
|
||||||
|
}
|
||||||
|
if rec := getAuth(t, h, adminAPI("/acme/demo/widgets"), plain); rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("revoked token: want 401, got %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSON(t *testing.T, v any) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func wellFormedPlain(v string) bool {
|
||||||
|
if len(v) != 44 || !strings.HasPrefix(v, "g15_") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range v[4:] {
|
||||||
|
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -162,14 +162,13 @@ func validateTokenScopeMessageKeys(reg *Registry, tr *phrasebook.Translator) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, scope := range reg.tokenScopes.ordered {
|
for _, scope := range reg.tokenScopes.ordered {
|
||||||
// Framework scopes use backend::lang.api_tokens.* keys added with the
|
who := scope.PluginID
|
||||||
// account token API; only plugin scopes are checked at boot here.
|
if who == "" {
|
||||||
if scope.PluginID == "" {
|
who = "framework"
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
for _, text := range []string{scope.Label, scope.Description} {
|
for _, text := range []string{scope.Label, scope.Description} {
|
||||||
if strings.Contains(text, "::") && !tr.Has(text) {
|
if strings.Contains(text, "::") && !tr.Has(text) {
|
||||||
return fmt.Errorf("cabana: token scope %s (%s) names missing phrase key %s", scope.Name, scope.PluginID, text)
|
return fmt.Errorf("cabana: token scope %s (%s) names missing phrase key %s", scope.Name, who, text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,59 +326,42 @@ func scopeCoversAll(limit *bouncer.ScopeLimit, cover []string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// scopeOverlaps is the D-17 picker/issue rule (Task 3 grows callers).
|
// scopeOverlaps is the D-17 picker/issue rule: a plugin scope is offered when
|
||||||
func scopeOverlaps(cat *tokenScopeCatalog, owner *bouncer.Principal, name string) bool {
|
// some registered permission code has a full match in the scope and the owner
|
||||||
if cat == nil || owner == nil {
|
// holds that code. "*" is always offered. "admin:read" is offered when the
|
||||||
|
// owner is a superuser or holds at least one catalog permission.
|
||||||
|
func scopeOverlaps(reg *Registry, owner *bouncer.Principal, name string) bool {
|
||||||
|
if reg == nil || reg.tokenScopes == nil || owner == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if name == scopeAll {
|
if name == scopeAll {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
scope, ok := cat.byName[name]
|
scope, ok := reg.tokenScopes.byName[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if scope.Full {
|
if scope.Full {
|
||||||
// admin:read: offered when superuser or holds at least one catalog code.
|
|
||||||
if owner.IsSuperuser {
|
if owner.IsSuperuser {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for code := range catPermissionCodes(cat) {
|
for code := range reg.permissions {
|
||||||
if ownerHas(owner, code) {
|
if ownerHas(owner, code) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fall through: also check against registry permissions via owner grants.
|
return false
|
||||||
for code := range owner.PermissionGrants {
|
}
|
||||||
if owner.PermissionGrants[code] {
|
for code := range reg.permissions {
|
||||||
|
for _, pattern := range scope.Permissions {
|
||||||
|
if scopeMatch(pattern, code) == matchFull && ownerHas(owner, code) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return owner.IsSuperuser
|
|
||||||
}
|
|
||||||
for _, c := range scope.Permissions {
|
|
||||||
if scopeMatch(c, c) == matchFull && ownerHas(owner, c) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// Some registered catalog code has a full match in the scope and ownerHas(c).
|
|
||||||
_ = c
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func catPermissionCodes(cat *tokenScopeCatalog) map[string]bool {
|
|
||||||
out := map[string]bool{}
|
|
||||||
if cat == nil {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
for _, scope := range cat.ordered {
|
|
||||||
for _, p := range scope.Permissions {
|
|
||||||
out[p] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func methodFilterScopes(cat *tokenScopeCatalog, stored []string, method string) (limit *bouncer.ScopeLimit, known int, methodOK int) {
|
func methodFilterScopes(cat *tokenScopeCatalog, stored []string, method string) (limit *bouncer.ScopeLimit, known int, methodOK int) {
|
||||||
if cat == nil {
|
if cat == nil {
|
||||||
return nil, 0, 0
|
return nil, 0, 0
|
||||||
|
|||||||
@@ -231,6 +231,16 @@ permissioneditor:
|
|||||||
locked: You cannot change this permission.
|
locked: You cannot change this permission.
|
||||||
empty: No permissions are defined yet.
|
empty: No permissions are defined yet.
|
||||||
other: Other
|
other: Other
|
||||||
|
api_tokens:
|
||||||
|
scope_all: Full access
|
||||||
|
scope_all_description: Everything you can do in the admin. Your account settings and tokens still need a signed-in session.
|
||||||
|
scope_admin_read: Read-only, whole admin
|
||||||
|
scope_admin_read_description: View everything you can see in the admin, without making changes.
|
||||||
|
name_required: Enter a name for the token.
|
||||||
|
name_too_long: The name may have at most 100 characters.
|
||||||
|
expires_invalid: Choose a valid expiry.
|
||||||
|
scopes_required: Choose at least one scope.
|
||||||
|
scope_unavailable: The scope :name is not available to you.
|
||||||
myaccount:
|
myaccount:
|
||||||
menu_label: My account
|
menu_label: My account
|
||||||
menu_description: Edit your name, email, subtitle and password.
|
menu_description: Edit your name, email, subtitle and password.
|
||||||
|
|||||||
@@ -253,6 +253,16 @@ permissioneditor:
|
|||||||
locked: Nie możesz zmienić tego uprawnienia.
|
locked: Nie możesz zmienić tego uprawnienia.
|
||||||
empty: Nie zdefiniowano jeszcze żadnych uprawnień.
|
empty: Nie zdefiniowano jeszcze żadnych uprawnień.
|
||||||
other: Inne
|
other: Inne
|
||||||
|
api_tokens:
|
||||||
|
scope_all: Pełny dostęp
|
||||||
|
scope_all_description: Wszystko, co możesz zrobić w panelu. Ustawienia konta i tokeny nadal wymagają zalogowania.
|
||||||
|
scope_admin_read: Tylko odczyt, cały panel
|
||||||
|
scope_admin_read_description: Podgląd wszystkiego, co widzisz w panelu, bez wprowadzania zmian.
|
||||||
|
name_required: Podaj nazwę tokenu.
|
||||||
|
name_too_long: Nazwa może mieć najwyżej 100 znaków.
|
||||||
|
expires_invalid: Wybierz poprawny termin ważności.
|
||||||
|
scopes_required: Wybierz co najmniej jeden zakres.
|
||||||
|
scope_unavailable: Zakres :name nie jest dla Ciebie dostępny.
|
||||||
myaccount:
|
myaccount:
|
||||||
menu_label: Moje konto
|
menu_label: Moje konto
|
||||||
menu_description: Zmień imię, nazwisko, e-mail, podtytuł i hasło.
|
menu_description: Zmień imię, nazwisko, e-mail, podtytuł i hasło.
|
||||||
|
|||||||
Reference in New Issue
Block a user