From ffdb5d00ca7875940489a32a7d2c7ecf18e8bb17 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 11 Oct 2026 01:51:11 +0200 Subject: [PATCH] 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 --- admin/openapi/admin.json | 449 ++++++++++++++++++- admin/src/api/schema.d.ts | 294 +++++++++++- modules/cabana/account_tokens.go | 306 +++++++++++++ modules/cabana/admin_openapi.go | 62 ++- modules/cabana/api_tokens.go | 141 +++++- modules/cabana/export_test.go | 2 +- modules/cabana/http.go | 4 + modules/cabana/openapi_conformance_test.go | 22 + modules/cabana/security_coverage_test.go | 4 + modules/cabana/token_postgres_smoke_test.go | 123 ++++- modules/cabana/token_scopes.go | 52 +-- modules/phrasebook/backend/lang/en/lang.yaml | 10 + modules/phrasebook/backend/lang/pl/lang.yaml | 10 + 13 files changed, 1430 insertions(+), 49 deletions(-) create mode 100644 modules/cabana/account_tokens.go diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 5b964f0..e0879fd 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -1,6 +1,151 @@ { "components": { "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": { "properties": { "current_password": { @@ -322,6 +467,42 @@ ], "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": { "properties": { "data": { @@ -394,6 +575,36 @@ ], "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": { "properties": { "data": { @@ -2176,7 +2387,7 @@ }, "securitySchemes": { "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", "name": "Authorization", "type": "apiKey" @@ -2193,7 +2404,7 @@ "paths": { "/account": { "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": { "content": { "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": { "post": { "parameters": [ diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index a777342..d65dcfc 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -14,7 +14,7 @@ export interface paths { get?: never; /** * 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: { parameters: { @@ -210,6 +210,247 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -4669,6 +4910,41 @@ export interface paths { export type webhooks = Record; export interface components { 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": { current_password?: string; email: string; @@ -4774,6 +5050,14 @@ export interface components { scripts: 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": { data: components["schemas"]["cabana.FileItem"][]; meta: components["schemas"]["cabana.SuccessMeta"]; @@ -4790,6 +5074,14 @@ export interface components { data: components["schemas"]["cabana.SettingsEntry"][]; 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": { data: components["schemas"]["cabana.AdminActionResult"]; meta: components["schemas"]["cabana.SuccessMeta"]; diff --git a/modules/cabana/account_tokens.go b/modules/cabana/account_tokens.go new file mode 100644 index 0000000..bcfbf16 --- /dev/null +++ b/modules/cabana/account_tokens.go @@ -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 +} \ No newline at end of file diff --git a/modules/cabana/admin_openapi.go b/modules/cabana/admin_openapi.go index a47c8dd..9e7ea81 100644 --- a/modules/cabana/admin_openapi.go +++ b/modules/cabana/admin_openapi.go @@ -7,7 +7,7 @@ package cabana // @securityDefinitions.apikey BackendBearer // @in header // @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 // to produce admin/openapi/admin.json, the document the SPA's TypeScript types @@ -204,7 +204,7 @@ func AdminMe() {} // AdminAccountUpdate documents PUT /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 // @Accept json // @Produce json @@ -218,6 +218,64 @@ func AdminMe() {} // @Router /account [put] 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. // // @Summary Upload my avatar diff --git a/modules/cabana/api_tokens.go b/modules/cabana/api_tokens.go index 07a968c..4af8acc 100644 --- a/modules/cabana/api_tokens.go +++ b/modules/cabana/api_tokens.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/lagoon" "gorm.io/gorm" ) @@ -67,10 +68,119 @@ func wellFormedAPIToken(v string) bool { return true } -// issueAPIToken mints a token for ownerID with the given scopes and optional -// expiry, inserts the row, and returns the plaintext exactly once. Task 3 -// grows this into the shared API/CLI validation path. -func issueAPIToken(db *gorm.DB, ownerID uint, name string, scopes []string, expiresAt *time.Time) (plain string, row *BackendAPIToken, err error) { +// cleanedTokenRequest is the validated create payload. +type cleanedTokenRequest struct { + Name string + 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 { 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 } +// 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. type tokenStore interface { FindUsableByHash(ctx context.Context, hash string, now time.Time) (*BackendAPIToken, error) diff --git a/modules/cabana/export_test.go b/modules/cabana/export_test.go index b4e4cc5..de47d34 100644 --- a/modules/cabana/export_test.go +++ b/modules/cabana/export_test.go @@ -22,6 +22,6 @@ func AdminAPIRoutes() []string { // scopes and optional expiry. Used by external Postgres smoke tests before // the HTTP create route exists. 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 } diff --git a/modules/cabana/http.go b/modules/cabana/http.go index f072118..e06e5e8 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -266,6 +266,10 @@ func (s *service) mount(r pact.Router) { g.Put("/account", interactiveOnly(requireAjax(s.accountUpdate))) g.Post("/account/avatar", interactiveOnly(requireAjax(s.avatarUpload))) 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) // Plugin field types (pact.HasFormFieldTypes): the SPA loads their // scripts and styles once after sign-in. diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index 5a93854..f63ea94 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -540,6 +540,27 @@ func conformCases() []conformCase { } return rec }, 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 { return e.send(t, http.MethodPost, "/auth/logout", nil, true) }, into[cabana.Envelope[cabana.AdminLogoutData]](), nil}, @@ -603,6 +624,7 @@ type conformEnv struct { partID uint partFileID uint childKey string + tokenID uint } // sendWith sends a raw body with extra headers through the assembled router. diff --git a/modules/cabana/security_coverage_test.go b/modules/cabana/security_coverage_test.go index e2bb3d2..c44304c 100644 --- a/modules/cabana/security_coverage_test.go +++ b/modules/cabana/security_coverage_test.go @@ -42,6 +42,10 @@ var phase09Routes = []adminRoute{ {key: "PUT /account"}, {key: "POST /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 /field-types"}, {key: "GET /settings"}, diff --git a/modules/cabana/token_postgres_smoke_test.go b/modules/cabana/token_postgres_smoke_test.go index d4d7e10..a7b11db 100644 --- a/modules/cabana/token_postgres_smoke_test.go +++ b/modules/cabana/token_postgres_smoke_test.go @@ -4,18 +4,20 @@ import ( "encoding/json" "io/fs" "net/http" + "os" + "path/filepath" + "strconv" + "strings" "testing" "testing/fstest" "git.golem15.com/golem15/summercms/modules/backpack" "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/party" "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 { @@ -154,3 +156,116 @@ func TestTokenTracerPostgres(t *testing.T) { 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 +} diff --git a/modules/cabana/token_scopes.go b/modules/cabana/token_scopes.go index 469aa5e..3966382 100644 --- a/modules/cabana/token_scopes.go +++ b/modules/cabana/token_scopes.go @@ -162,14 +162,13 @@ func validateTokenScopeMessageKeys(reg *Registry, tr *phrasebook.Translator) err return nil } for _, scope := range reg.tokenScopes.ordered { - // Framework scopes use backend::lang.api_tokens.* keys added with the - // account token API; only plugin scopes are checked at boot here. - if scope.PluginID == "" { - continue + who := scope.PluginID + if who == "" { + who = "framework" } for _, text := range []string{scope.Label, scope.Description} { 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 } -// scopeOverlaps is the D-17 picker/issue rule (Task 3 grows callers). -func scopeOverlaps(cat *tokenScopeCatalog, owner *bouncer.Principal, name string) bool { - if cat == nil || owner == nil { +// scopeOverlaps is the D-17 picker/issue rule: a plugin scope is offered when +// some registered permission code has a full match in the scope and the owner +// 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 } if name == scopeAll { return true } - scope, ok := cat.byName[name] + scope, ok := reg.tokenScopes.byName[name] if !ok { return false } if scope.Full { - // admin:read: offered when superuser or holds at least one catalog code. if owner.IsSuperuser { return true } - for code := range catPermissionCodes(cat) { + for code := range reg.permissions { if ownerHas(owner, code) { return true } } - // Fall through: also check against registry permissions via owner grants. - for code := range owner.PermissionGrants { - if owner.PermissionGrants[code] { + return false + } + for code := range reg.permissions { + for _, pattern := range scope.Permissions { + if scopeMatch(pattern, code) == matchFull && ownerHas(owner, code) { 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 } -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) { if cat == nil { return nil, 0, 0 diff --git a/modules/phrasebook/backend/lang/en/lang.yaml b/modules/phrasebook/backend/lang/en/lang.yaml index 16e7924..704d2b9 100644 --- a/modules/phrasebook/backend/lang/en/lang.yaml +++ b/modules/phrasebook/backend/lang/en/lang.yaml @@ -231,6 +231,16 @@ permissioneditor: locked: You cannot change this permission. empty: No permissions are defined yet. 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: menu_label: My account menu_description: Edit your name, email, subtitle and password. diff --git a/modules/phrasebook/backend/lang/pl/lang.yaml b/modules/phrasebook/backend/lang/pl/lang.yaml index ded852e..e64067b 100644 --- a/modules/phrasebook/backend/lang/pl/lang.yaml +++ b/modules/phrasebook/backend/lang/pl/lang.yaml @@ -253,6 +253,16 @@ permissioneditor: locked: Nie możesz zmienić tego uprawnienia. empty: Nie zdefiniowano jeszcze żadnych uprawnień. 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: menu_label: Moje konto menu_description: Zmień imię, nazwisko, e-mail, podtytuł i hasło.