From d60f5f7f313e16cae587472746913d44b393bed2 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 13 Sep 2026 09:24:42 -0400 Subject: [PATCH] feat(admin): add protected account lifecycle endpoints --- README.md | 13 +- cmd/beacon/main.go | 2 + docs/docs.go | 358 +++++++++++++++++++++++++ docs/swagger.json | 358 +++++++++++++++++++++++++ docs/swagger.yaml | 236 ++++++++++++++++ internal/api/account_requests.go | 8 + internal/api/handlers/accounts.go | 173 ++++++++++++ internal/api/handlers/accounts_test.go | 147 ++++++++++ internal/api/router/accounts_test.go | 53 ++++ 9 files changed, 1346 insertions(+), 2 deletions(-) create mode 100644 internal/api/account_requests.go create mode 100644 internal/api/handlers/accounts.go create mode 100644 internal/api/handlers/accounts_test.go create mode 100644 internal/api/router/accounts_test.go diff --git a/README.md b/README.md index 2609e74..6767640 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,7 @@ broker workers, not connection status or a tunable processing-worker pool). The CORS lists are the options supplied to the middleware; its normal matching normalization still applies. The response excludes credential fields, broker addresses, channel material, database settings and -other configuration. Account operations are not implemented; unknown admin paths -return 404 and unsupported +other configuration. Unknown admin paths return 404 and unsupported methods on the config endpoint return 405 after authentication. Global CORS preflights remain public. Use a long, randomly generated key, keep it out of source control and logs, and send it only in the Authorization header, @@ -158,6 +157,16 @@ auth/credential fields and broker count cannot be changed here; there is no configurable `ingest.worker_count`. Cross-origin admin clients need PUT allowed in the saved CORS methods. CORS controls browser access, not authentication. +Operator accounts are available at `GET/POST /api/v1/admin/accounts` and +`GET/DELETE /api/v1/admin/accounts/{id}`. POST accepts a JSON `name` field in a +body up to 4 KiB; names are trimmed, case-sensitive and limited to 128 Unicode +characters without control characters. Active names are unique. DELETE soft +deactivates the record (204); missing IDs return 404 and an already inactive +record returns 409. A deactivated name may be reused by a new account. +Lists include active and inactive records, newest first, without pagination. +These are operator-defined records; no login, session or API token is created. +Cross-origin clients must have their methods allowed in the existing CORS config. + ### Environment variables (`.env`) | Variable | Default | Description | diff --git a/cmd/beacon/main.go b/cmd/beacon/main.go index b518ab1..39f1c61 100644 --- a/cmd/beacon/main.go +++ b/cmd/beacon/main.go @@ -18,6 +18,7 @@ import ( "github.com/MeshCore-Beacon/beacon-server/db" _ "github.com/MeshCore-Beacon/beacon-server/docs" "github.com/MeshCore-Beacon/beacon-server/internal/api" + "github.com/MeshCore-Beacon/beacon-server/internal/api/handlers" "github.com/MeshCore-Beacon/beacon-server/internal/api/router" "github.com/MeshCore-Beacon/beacon-server/internal/background" "github.com/MeshCore-Beacon/beacon-server/internal/cache" @@ -307,6 +308,7 @@ func main() { MaxConnsPerIP: resolved.MaxConnsPerIP, MaxConnectsPerMinute: resolved.MaxConnectsPerMinute, CORS: cfg.CORS, Server: cfg.Server, Auth: cfg.Auth, RateLimit: resolved.RateLimit, + AdminRoutes: map[string]http.Handler{"/accounts": handlers.AccountsRouter(store)}, }) srv := &http.Server{ diff --git a/docs/docs.go b/docs/docs.go index 7b09657..5973f96 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -22,6 +22,322 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/admin/accounts": { + "get": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Includes active and inactive accounts, newest first. These records are not login users.", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "List operator accounts", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, + "post": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Trims the name; names are case-sensitive and unique among active accounts. No credential or login is created.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Create an operator account", + "parameters": [ + { + "description": "Account name (1-128 Unicode characters, no control characters); body at most 4 KiB", + "name": "account", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "413": { + "description": "Request Entity Too Large", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "415": { + "description": "Unsupported Media Type", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + } + }, + "/admin/accounts/{id}": { + "get": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Returns active or inactive account records.", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Get an operator account", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Account UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, + "delete": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Soft deactivation preserves the record. Its name may be reused by a new account.", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Deactivate an operator account", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Account UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Deactivated" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + } + }, "/admin/config": { "get": { "security": [ @@ -2640,6 +2956,37 @@ const docTemplate = `{ } }, "definitions": { + "github_com_MeshCore-Beacon_beacon-server_internal_api.Account": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "deactivated_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account" + } + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig": { "type": "object", "properties": { @@ -2893,6 +3240,17 @@ const docTemplate = `{ } } }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 9803c08..52f700e 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -20,6 +20,322 @@ "host": "localhost:8080", "basePath": "/api/v1", "paths": { + "/admin/accounts": { + "get": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Includes active and inactive accounts, newest first. These records are not login users.", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "List operator accounts", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, + "post": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Trims the name; names are case-sensitive and unique among active accounts. No credential or login is created.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Create an operator account", + "parameters": [ + { + "description": "Account name (1-128 Unicode characters, no control characters); body at most 4 KiB", + "name": "account", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "413": { + "description": "Request Entity Too Large", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "415": { + "description": "Unsupported Media Type", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + } + }, + "/admin/accounts/{id}": { + "get": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Returns active or inactive account records.", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Get an operator account", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Account UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, + "delete": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Soft deactivation preserves the record. Its name may be reused by a new account.", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Deactivate an operator account", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Account UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Deactivated" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + } + }, "/admin/config": { "get": { "security": [ @@ -2638,6 +2954,37 @@ } }, "definitions": { + "github_com_MeshCore-Beacon_beacon-server_internal_api.Account": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "deactivated_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account" + } + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig": { "type": "object", "properties": { @@ -2891,6 +3238,17 @@ } } }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f1d4293..6ef3079 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,25 @@ basePath: /api/v1 definitions: + github_com_MeshCore-Beacon_beacon-server_internal_api.Account: + properties: + active: + type: boolean + created_at: + type: string + deactivated_at: + type: string + id: + type: string + name: + type: string + type: object + github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList: + properties: + items: + items: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account' + type: array + type: object github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig: properties: configured: @@ -177,6 +197,13 @@ definitions: nodeTypeName: type: string type: object + github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest: + properties: + name: + type: string + required: + - name + type: object github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop: properties: fromIata: @@ -1289,6 +1316,215 @@ info: title: MeshCore Beacon API version: 1.6.0 paths: + /admin/accounts: + get: + description: Includes active and inactive accounts, newest first. These records + are not login users. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList' + "401": + description: Unauthorized + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + security: + - AdminKey: [] + summary: List operator accounts + tags: + - Admin + post: + consumes: + - application/json + description: Trims the name; names are case-sensitive and unique among active + accounts. No credential or login is created. + parameters: + - description: Account name (1-128 Unicode characters, no control characters); + body at most 4 KiB + in: body + name: account + required: true + schema: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account' + "400": + description: Bad Request + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "409": + description: Conflict + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "413": + description: Request Entity Too Large + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "415": + description: Unsupported Media Type + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + security: + - AdminKey: [] + summary: Create an operator account + tags: + - Admin + /admin/accounts/{id}: + delete: + description: Soft deactivation preserves the record. Its name may be reused + by a new account. + parameters: + - description: Account UUID + format: uuid + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "204": + description: Deactivated + "400": + description: Bad Request + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "404": + description: Not Found + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "409": + description: Conflict + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + security: + - AdminKey: [] + summary: Deactivate an operator account + tags: + - Admin + get: + description: Returns active or inactive account records. + parameters: + - description: Account UUID + format: uuid + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account' + "400": + description: Bad Request + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "404": + description: Not Found + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + security: + - AdminKey: [] + summary: Get an operator account + tags: + - Admin /admin/config: get: description: Returns current CORS options, auth configuration status and configured diff --git a/internal/api/account_requests.go b/internal/api/account_requests.go new file mode 100644 index 0000000..f040f98 --- /dev/null +++ b/internal/api/account_requests.go @@ -0,0 +1,8 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package api + +type CreateAccountRequest struct { + Name string `json:"name" validate:"required"` +} diff --git a/internal/api/handlers/accounts.go b/internal/api/handlers/accounts.go new file mode 100644 index 0000000..92c1fc2 --- /dev/null +++ b/internal/api/handlers/accounts.go @@ -0,0 +1,173 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handlers + +import ( + "encoding/json" + "errors" + "io" + "mime" + "net/http" + + "github.com/MeshCore-Beacon/beacon-server/internal/api" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +// AccountsRouter must be mounted inside the authenticated admin subtree. +func AccountsRouter(store api.AccountStore) http.Handler { + if store == nil { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { respondError(w, 503, "account storage is unavailable") }) + } + r := chi.NewRouter() + r.Get("/", listAccounts(store)) + r.Post("/", createAccount(store)) + r.Get("/{id}", getAccount(store)) + r.Delete("/{id}", deactivateAccount(store)) + return r +} + +// listAccounts godoc +// @Summary List operator accounts +// @Description Includes active and inactive accounts, newest first. These records are not login users. +// @Tags Admin +// @Produce json +// @Security AdminKey +// @Success 200 {object} api.AccountList +// @Failure 401,503,500 {object} map[string]APIError +// @Router /admin/accounts [get] +func listAccounts(store api.AccountStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + items, err := store.ListAccounts(r.Context()) + if err != nil { + accountError(w, err) + return + } + if items == nil { + items = []api.Account{} + } + respond(w, 200, api.AccountList{Items: items}) + } +} + +// createAccount godoc +// @Summary Create an operator account +// @Description Trims the name; names are case-sensitive and unique among active accounts. No credential or login is created. +// @Tags Admin +// @Accept json +// @Produce json +// @Security AdminKey +// @Param account body api.CreateAccountRequest true "Account name (1-128 Unicode characters, no control characters); body at most 4 KiB" +// @Success 201 {object} api.Account +// @Failure 400,401,409,413,415,503,500 {object} map[string]APIError +// @Router /admin/accounts [post] +func createAccount(store api.AccountStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + respondError(w, 415, "Content-Type must be application/json") + return + } + r.Body = http.MaxBytesReader(w, r.Body, 4096) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + var input api.CreateAccountRequest + err = decoder.Decode(&input) + if err == nil { + var extra any + err = decoder.Decode(&extra) + if errors.Is(err, io.EOF) { + err = nil + } else if err == nil { + err = errors.New("multiple JSON values") + } + } + if err != nil { + var sizeError *http.MaxBytesError + if errors.As(err, &sizeError) { + respondError(w, 413, "request body exceeds 4 KiB") + } else { + respondError(w, 400, "invalid account JSON") + } + return + } + name, err := api.NormalizeAccountName(input.Name) + if err != nil { + accountError(w, err) + return + } + account, err := store.CreateAccount(r.Context(), name) + if err != nil { + accountError(w, err) + return + } + respond(w, 201, account) + } +} + +// getAccount godoc +// @Summary Get an operator account +// @Description Returns active or inactive account records. +// @Tags Admin +// @Produce json +// @Security AdminKey +// @Param id path string true "Account UUID" format(uuid) +// @Success 200 {object} api.Account +// @Failure 400,401,404,503,500 {object} map[string]APIError +// @Router /admin/accounts/{id} [get] +func getAccount(store api.AccountStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + respondError(w, 400, "invalid account ID") + return + } + account, err := store.GetAccount(r.Context(), id) + if err != nil { + accountError(w, err) + return + } + respond(w, 200, account) + } +} + +// deactivateAccount godoc +// @Summary Deactivate an operator account +// @Description Soft deactivation preserves the record. Its name may be reused by a new account. +// @Tags Admin +// @Produce json +// @Security AdminKey +// @Param id path string true "Account UUID" format(uuid) +// @Success 204 "Deactivated" +// @Failure 400,401,404,409,503,500 {object} map[string]APIError +// @Router /admin/accounts/{id} [delete] +func deactivateAccount(store api.AccountStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + respondError(w, 400, "invalid account ID") + return + } + if err := store.DeactivateAccount(r.Context(), id); err != nil { + accountError(w, err) + return + } + w.WriteHeader(204) + } +} + +func accountError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, api.ErrAccountNameInvalid): + respondError(w, 400, api.ErrAccountNameInvalid.Error()) + case errors.Is(err, api.ErrAccountNameConflict): + respondError(w, 409, api.ErrAccountNameConflict.Error()) + case errors.Is(err, api.ErrAccountNotFound): + respondError(w, 404, api.ErrAccountNotFound.Error()) + case errors.Is(err, api.ErrAccountInactive): + respondError(w, 409, api.ErrAccountInactive.Error()) + default: + respondError(w, 500, "internal server error") + } +} diff --git a/internal/api/handlers/accounts_test.go b/internal/api/handlers/accounts_test.go new file mode 100644 index 0000000..d6b9683 --- /dev/null +++ b/internal/api/handlers/accounts_test.go @@ -0,0 +1,147 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/MeshCore-Beacon/beacon-server/internal/api" + mw "github.com/MeshCore-Beacon/beacon-server/internal/api/middleware" + "github.com/google/uuid" +) + +type accountStub struct { + account api.Account + items []api.Account + err error + calls int + name string + id uuid.UUID +} + +func (s *accountStub) CreateAccount(_ context.Context, name string) (api.Account, error) { + s.calls++ + s.name = name + return s.account, s.err +} +func (s *accountStub) ListAccounts(context.Context) ([]api.Account, error) { + s.calls++ + return s.items, s.err +} +func (s *accountStub) GetAccount(_ context.Context, id uuid.UUID) (api.Account, error) { + s.calls++ + s.id = id + return s.account, s.err +} +func (s *accountStub) DeactivateAccount(_ context.Context, id uuid.UUID) error { + s.calls++ + s.id = id + return s.err +} + +func accountRequest(handler http.Handler, method, path, body, contentType, token string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + +func TestAccountInputAndErrors(t *testing.T) { + id := uuid.New() + for _, tc := range []struct { + name, method, path, body, media string + err error + status, calls int + }{ + {"create", "POST", "/", `{"name":" Alice "}`, "application/json", nil, 201, 1}, + {"media parameters", "POST", "/", `{"name":"Alice"}`, "application/json; charset=utf-8", nil, 201, 1}, + {"empty", "POST", "/", `{}`, "application/json", nil, 400, 0}, + {"null", "POST", "/", `null`, "application/json", nil, 400, 0}, + {"wrong field", "POST", "/", `{"name":"Alice","admin":true}`, "application/json", nil, 400, 0}, + {"trailing JSON", "POST", "/", `{"name":"Alice"} {}`, "application/json", nil, 400, 0}, + {"malformed", "POST", "/", `{"name":`, "application/json", nil, 400, 0}, + {"wrong name type", "POST", "/", `{"name":3}`, "application/json", nil, 400, 0}, + {"control", "POST", "/", `{"name":"x\u0000y"}`, "application/json", nil, 400, 0}, + {"oversized", "POST", "/", `{"name":"` + strings.Repeat("a", 5000) + `"}`, "application/json", nil, 413, 0}, + {"oversized trailing space", "POST", "/", `{"name":"Alice"}` + strings.Repeat(" ", 5000), "application/json", nil, 413, 0}, + {"missing media", "POST", "/", `{"name":"Alice"}`, "", nil, 415, 0}, + {"wrong media", "POST", "/", `{"name":"Alice"}`, "text/plain", nil, 415, 0}, + {"conflict", "POST", "/", `{"name":"Alice"}`, "application/json", fmt.Errorf("private-error: %w", api.ErrAccountNameConflict), 409, 1}, + {"get", "GET", "/" + id.String(), "", "", nil, 200, 1}, + {"bad ID", "GET", "/invalid", "", "", nil, 400, 0}, + {"bad delete ID", "DELETE", "/invalid", "", "", nil, 400, 0}, + {"not found", "GET", "/" + id.String(), "", "", api.ErrAccountNotFound, 404, 1}, + {"deactivate", "DELETE", "/" + id.String(), "", "", nil, 204, 1}, + {"inactive", "DELETE", "/" + id.String(), "", "", api.ErrAccountInactive, 409, 1}, + {"delete absent", "DELETE", "/" + id.String(), "", "", api.ErrAccountNotFound, 404, 1}, + {"list database failure", "GET", "/", "", "", errors.New("private-error"), 500, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + store := &accountStub{account: api.Account{ID: id, Name: "Alice", CreatedAt: time.Unix(1, 0).UTC(), Active: true}, err: tc.err} + handler := mw.BearerAuth("test-key", AccountsRouter(store)) + w := accountRequest(handler, tc.method, tc.path, tc.body, tc.media, "test-key") + if w.Code != tc.status || store.calls != tc.calls { + t.Fatalf("status=%d calls=%d", w.Code, store.calls) + } + if w.Header().Get("Cache-Control") != "no-store" || strings.Contains(w.Body.String(), "private-error") { + t.Fatal("cache policy or error disclosure") + } + if tc.status == 204 { + if w.Body.Len() != 0 { + t.Fatal("204 body") + } + return + } + if w.Header().Get("Content-Type") != "application/json" || !json.Valid(w.Body.Bytes()) { + t.Fatal("invalid JSON response") + } + if tc.status == 201 && store.name != "Alice" { + t.Fatal("untrimmed name reached storage") + } + if (tc.method == "DELETE" || tc.method == "GET") && tc.calls > 0 && tc.path != "/" && store.id != id { + t.Fatal("wrong account ID") + } + }) + } +} + +func TestAccountListAndAuthorization(t *testing.T) { + for _, items := range [][]api.Account{nil, {{ID: uuid.New(), Name: "inactive", Active: false}}} { + store := &accountStub{items: items} + handler := mw.BearerAuth("test-key", AccountsRouter(store)) + w := accountRequest(handler, "GET", "/", "", "", "test-key") + var body api.AccountList + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &body) != nil || body.Items == nil || len(body.Items) != len(items) { + t.Fatal("account list shape") + } + } + for _, key := range []string{"", "test-key"} { + store := &accountStub{} + handler := mw.BearerAuth(key, AccountsRouter(store)) + for _, method := range []string{"GET", "POST", "DELETE"} { + w := accountRequest(handler, method, "/", "{}", "application/json", "") + want := 401 + if key == "" { + want = 503 + } + if w.Code != want || store.calls != 0 { + t.Fatal("unauthorized store access") + } + } + } +} diff --git a/internal/api/router/accounts_test.go b/internal/api/router/accounts_test.go new file mode 100644 index 0000000..7cd7fd1 --- /dev/null +++ b/internal/api/router/accounts_test.go @@ -0,0 +1,53 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package router + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MeshCore-Beacon/beacon-server/internal/api" + "github.com/MeshCore-Beacon/beacon-server/internal/api/handlers" + "github.com/MeshCore-Beacon/beacon-server/internal/config" + "github.com/google/uuid" +) + +type routedAccountStore struct { + api.AccountStore + calls int +} + +func (s *routedAccountStore) CreateAccount(_ context.Context, name string) (api.Account, error) { + s.calls++ + return api.Account{ID: uuid.New(), Name: name, Active: true}, nil +} +func TestAccountRouterUsesProtectedStore(t *testing.T) { + store := &routedAccountStore{} + r := New(nil, nil, nil, Options{Auth: config.AuthConfig{APIKey: "test-key"}, AdminRoutes: map[string]http.Handler{"/accounts": handlers.AccountsRouter(store)}}) + for _, token := range []string{"", "wrong", "test-key"} { + req := httptest.NewRequest("POST", "/api/v1/admin/accounts", strings.NewReader(`{"name":"test"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + want := 401 + if token == "test-key" { + want = 201 + } + if w.Code != want { + t.Fatalf("route status=%d want=%d", w.Code, want) + } + } + if store.calls != 1 { + t.Fatal("auth boundary was bypassed") + } + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("POST", "/api/v1/accounts", nil)) + if w.Code != 404 || store.calls != 1 { + t.Fatal("accounts mounted publicly") + } +}