feat(api): implement API key management and authentication enhancements
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 28s
CI / go (push) Failing after 24s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped

- Added endpoints for managing API keys, including creation, retrieval, updating, and revocation.
- Introduced a new Auth session endpoint to retrieve current tenant and role information.
- Updated the authentication middleware to support API key-based authentication and track last used timestamps.
- Enhanced documentation to reflect new API key functionalities and usage guidelines.
- Improved logging for demo authentication scenarios.
This commit is contained in:
Denozordec
2026-05-21 11:26:17 +07:00
parent 880d77810a
commit 6329a4df27
28 changed files with 1682 additions and 39 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ func main() {
tid, mCDN, mIP, rev, sp := srv.Store().DemoIDs()
log.Printf("demo tenant=%s module_cdn=%s module_ip_ranges=%s revision=%s speaker=%s", tid, mCDN, mIP, rev, sp)
log.Printf("example: EVOBGP_API_KEYS=op|%s|operator,node|%s|node", tid, tid)
log.Printf("with EVOBGP_DEV_INSECURE=1 use Authorization: Bearer dev (operator, demo tenant only)")
log.Printf("demo auth: Authorization: Bearer dev (operator, demo tenant only)")
}
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
+17 -3
View File
@@ -22,6 +22,19 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
При включённом демо-сиде сервер при старте может вывести в лог готовую подсказку с реальным `tenant_id` из БД — см. лог `evobgp-api` / `evobgp-all`.
Ключи из `EVOBGP_API_KEYS` загружаются при старте и **дополняют** ключи из таблицы `api_key` в БД (break-glass / bootstrap). После первого operator-ключа можно создавать остальные через API или веб-настройки.
### Управление через API и UI
При подключённой БД operator может:
- `GET|POST /v1/api-keys`, `GET|PATCH|DELETE /v1/api-keys/{id}`, `POST /v1/api-keys/{id}/rotate` — см. OpenAPI, тег **API keys**.
- В веб-панели: **Права доступа** (`/access`) → блок «API-ключи» (только для роли `operator`). Токен для браузера — в **Настройки** (`/settings`).
Полный токен возвращается **один раз** в ответе `201` (создание) и `200` (ротация). В списках — только `prefix` (первые 8 символов). В БД хранится SHA-256 токена, не plaintext.
`GET /v1/auth/session` — текущие `tenant_id` и `role` (для UI).
### Роли
| Роль | Уровень | Назначение |
@@ -33,11 +46,11 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
Обратное ограничение: для эндпоинтов ноды требуется именно роль **`node`**; остальные роли получают отказ.
### Режим разработки `EVOBGP_DEV_INSECURE`
### Токен `dev` (локальная разработка)
Если установлено `EVOBGP_DEV_INSECURE=1` и в store доступен демо-tenant (`DemoIDs`), то запрос с заголовком **`Authorization: Bearer dev`** получает контекст **`operator`** для этого tenant.
Если в store доступен демо-tenant (`DemoIDs`, обычно `EVOBGP_SEED_DEMO` не равен `0`), заголовок **`Authorization: Bearer dev`** даёт роль **`operator`** для этого tenant. **Не зависит** от `EVOBGP_DEV_INSECURE`.
**Запрещено** в продакшене: любой, кто знает заголовок, получает полные права оператора на демо-данные. В reference Compose (`deploy/compose/docker-compose.yaml`) флаг включён только для локальной разработки.
**Запрещено** в продакшене: не оставляйте demo-seed с известным токеном `dev` на боевых данных. Переменная `EVOBGP_DEV_INSECURE` в текущей версии **не влияет** на аутентификацию (оставлена в compose для совместимости; не включайте в production — см. SEC-02 в инженерных правилах).
### Синхронные «тяжёлые» GET (control plane)
@@ -93,6 +106,7 @@ http://localhost:5173,http://127.0.0.1:5173,https://ui.example.com
| GET модули, ревизии, peers, speakers | да | да | да | нет |
| POST/PATCH/DELETE CRUD сущностей | нет | да | да | нет |
| apply, rollback, PATCH settings | нет | нет | да | нет |
| Управление API-ключами (`/v1/api-keys`) | нет | нет | да | нет |
| bundle, latest revision, enroll | нет | нет | нет | да |
Точные проверки по каждому маршруту — в коде `internal/httpapi` и в схеме безопасности операций в OpenAPI.
+6
View File
@@ -44,6 +44,12 @@
- `GET|POST /v1/communities`
- `GET|PATCH|DELETE /v1/communities/{id}`
### API keys
- `GET /v1/auth/session` — tenant и роль текущего ключа
- `GET|POST /v1/api-keys` — список и создание (operator)
- `GET|PATCH|DELETE /v1/api-keys/{id}`, `POST /v1/api-keys/{id}/rotate`
### Peers
- `GET /v1/peers`, `POST /v1/peers`
+250
View File
@@ -45,6 +45,10 @@ tags:
description: "API для evobgp-node (бандлы ревизий и enrollment). Отдельный ключ или mTLS, роль node."
- name: Settings
description: Глобальные настройки и feature flags; изменение - только operator.
- name: API keys
description: Управление API-ключами tenant (operator). Секрет возвращается только при создании и ротации.
- name: Auth
description: Сессия текущего API-ключа (tenant и роль).
security:
- bearerAuth: []
@@ -135,6 +139,12 @@ components:
required: true
schema:
$ref: "#/components/schemas/ResourceId"
ApiKeyId:
name: id
in: path
required: true
schema:
$ref: "#/components/schemas/ResourceId"
SourceId:
name: source_id
in: path
@@ -639,6 +649,82 @@ components:
vault_secret_ref:
type: ["string", "null"]
AuthSession:
type: object
required: [tenant_id, role]
properties:
tenant_id:
$ref: "#/components/schemas/ResourceId"
role:
type: string
enum: [viewer, editor, operator, node]
ApiKey:
type: object
required: [id, name, role, prefix, created_at, updated_at]
properties:
id:
$ref: "#/components/schemas/ResourceId"
name:
type: string
role:
type: string
enum: [viewer, editor, operator, node]
prefix:
type: string
description: Первые 8 символов токена для идентификации в UI.
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
expires_at:
type: ["string", "null"]
format: date-time
revoked_at:
type: ["string", "null"]
format: date-time
last_used_at:
type: ["string", "null"]
format: date-time
additionalProperties: true
ApiKeyCreate:
type: object
required: [name, role]
properties:
name:
type: string
role:
type: string
enum: [viewer, editor, operator, node]
expires_at:
type: ["string", "null"]
format: date-time
ApiKeyPatch:
type: object
properties:
name:
type: string
role:
type: string
enum: [viewer, editor, operator, node]
expires_at:
type: ["string", "null"]
format: date-time
ApiKeyCreated:
allOf:
- $ref: "#/components/schemas/ApiKey"
- type: object
required: [token]
properties:
token:
type: string
description: Полный Bearer-токен; показывается один раз.
BgpCommunity:
type: object
required:
@@ -2643,6 +2729,170 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/auth/session:
get:
tags: [Auth]
summary: Текущая сессия API-ключа
operationId: getAuthSession
parameters:
- $ref: "#/components/parameters/TenantId"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/AuthSession"
"401":
$ref: "#/components/responses/Unauthorized"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/api-keys:
get:
tags: [API keys]
summary: Список API-ключей tenant
description: Только роль **operator**. Секреты не возвращаются.
operationId: listApiKeys
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
type: object
required: [items, has_more]
properties:
items:
type: array
items:
$ref: "#/components/schemas/ApiKey"
next_cursor:
type: ["string", "null"]
has_more:
type: boolean
"403":
$ref: "#/components/responses/Forbidden"
default:
$ref: "#/components/responses/DefaultProblem"
post:
tags: [API keys]
summary: Создать API-ключ
operationId: createApiKey
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKeyCreate"
responses:
"201":
description: Ключ создан; token в ответе один раз.
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKeyCreated"
"403":
$ref: "#/components/responses/Forbidden"
"422":
$ref: "#/components/responses/UnprocessableEntity"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/api-keys/{id}:
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/ApiKeyId"
get:
tags: [API keys]
summary: Получить метаданные API-ключа
operationId: getApiKey
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKey"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
patch:
tags: [API keys]
summary: Обновить API-ключ
operationId: patchApiKey
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKeyPatch"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKey"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
delete:
tags: [API keys]
summary: Отозвать API-ключ
operationId: revokeApiKey
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"204":
description: Отозван.
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/api-keys/{id}/rotate:
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/ApiKeyId"
post:
tags: [API keys]
summary: Ротировать секрет API-ключа
description: Выдаёт новый token; старый перестаёт работать сразу.
operationId: rotateApiKey
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKeyCreated"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/settings:
get:
tags: [Settings]
+34
View File
@@ -0,0 +1,34 @@
// Package authkey generates API tokens and derives lookup hashes (no persistence).
package authkey
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
)
const tokenPrefix = "evobgp_"
// GenerateToken returns a new bearer token (evobgp_ + 32 random bytes, base64url).
func GenerateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("authkey: generate token: %w", err)
}
return tokenPrefix + base64.RawURLEncoding.EncodeToString(b), nil
}
// HashToken returns SHA-256 of the full token (32 bytes).
func HashToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
// Prefix returns the first 8 characters of the token for display.
func Prefix(token string) string {
if len(token) <= 8 {
return token
}
return token[:8]
}
+66
View File
@@ -0,0 +1,66 @@
package httpapi
import (
"crypto/sha256"
"encoding/hex"
"sync"
"evobgp/internal/store"
)
type apiKeyResolver struct {
mu sync.RWMutex
envByToken map[string]apiKeyRecord
byHash map[string]apiKeyRecord
}
func newAPIKeyResolver(envSpec string, st store.Backend) (*apiKeyResolver, error) {
r := &apiKeyResolver{
envByToken: make(map[string]apiKeyRecord),
byHash: make(map[string]apiKeyRecord),
}
for _, rec := range parseAPIKeysSpec(envSpec) {
r.envByToken[rec.token] = rec
}
return r, r.reloadFromStore(st)
}
func (r *apiKeyResolver) reloadFromStore(st store.Backend) error {
rows, err := st.ListActiveAPIKeyHashes()
if err != nil {
return err
}
byHash := make(map[string]apiKeyRecord, len(rows))
for _, row := range rows {
if len(row.TokenHash) != 32 {
continue
}
byHash[hex.EncodeToString(row.TokenHash)] = apiKeyRecord{
token: "",
tenantID: row.TenantID,
role: row.Role,
keyID: row.ID,
}
}
r.mu.Lock()
r.byHash = byHash
r.mu.Unlock()
return nil
}
func (r *apiKeyResolver) Reload(st store.Backend) error {
return r.reloadFromStore(st)
}
func (r *apiKeyResolver) Lookup(raw string) (apiKeyRecord, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
if rec, ok := r.envByToken[raw]; ok {
return rec, true
}
sum := sha256.Sum256([]byte(raw))
key := hex.EncodeToString(sum[:])
rec, ok := r.byHash[key]
return rec, ok
}
+13 -21
View File
@@ -15,6 +15,7 @@ type Auth struct {
TenantID string
Role string // viewer, editor, operator, node
Token string
APIKeyID string // non-empty for DB-managed keys
}
func authFromContext(ctx context.Context) (Auth, bool) {
@@ -26,6 +27,7 @@ type apiKeyRecord struct {
token string
tenantID string
role string
keyID string // set for DB-managed keys (last_used_at)
}
func parseAPIKeysSpec(spec string) []apiKeyRecord {
@@ -54,20 +56,6 @@ func parseAPIKeysSpec(spec string) []apiKeyRecord {
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.insecureDev {
h := r.Header.Get("Authorization")
const p = "Bearer "
if strings.HasPrefix(h, p) {
tok := strings.TrimSpace(strings.TrimPrefix(h, p))
if tok == "dev" {
if a, ok := s.devAuth(); ok {
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
return
}
}
}
}
h := r.Header.Get("Authorization")
const p = "Bearer "
if !strings.HasPrefix(h, p) {
@@ -75,18 +63,22 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
return
}
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
var matched *apiKeyRecord
for i := range s.apiKeys {
if s.apiKeys[i].token == raw {
matched = &s.apiKeys[i]
break
if raw == "dev" {
if a, ok := s.devAuth(); ok {
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
return
}
}
if matched == nil {
matched, ok := s.keyResolver.Lookup(raw)
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
return
}
a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw}
a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw, APIKeyID: matched.keyID}
if matched.keyID != "" {
go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(matched.keyID)
}
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
})
+217
View File
@@ -0,0 +1,217 @@
package httpapi
import (
"encoding/json"
"net/http"
"strings"
"time"
"evobgp/internal/store"
)
func (s *Server) registerAPIKeyRoutes(m *http.ServeMux) {
m.HandleFunc("GET /auth/session", s.handleAuthSession)
m.HandleFunc("GET /api-keys", s.handleListAPIKeys)
m.HandleFunc("POST /api-keys", s.handlePostAPIKey)
m.HandleFunc("GET /api-keys/{id}", s.handleGetAPIKey)
m.HandleFunc("PATCH /api-keys/{id}", s.handlePatchAPIKey)
m.HandleFunc("DELETE /api-keys/{id}", s.handleDeleteAPIKey)
m.HandleFunc("POST /api-keys/{id}/rotate", s.handleRotateAPIKey)
}
func (s *Server) handleAuthSession(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"tenant_id": a.TenantID,
"role": a.Role,
})
}
func apiKeyJSON(k *store.APIKey) map[string]any {
m := map[string]any{
"id": k.ID,
"name": k.Name,
"role": k.Role,
"prefix": k.Prefix,
"created_at": k.CreatedAt.UTC().Format(time.RFC3339),
"updated_at": k.UpdatedAt.UTC().Format(time.RFC3339),
}
if k.ExpiresAt != nil {
m["expires_at"] = k.ExpiresAt.UTC().Format(time.RFC3339)
} else {
m["expires_at"] = nil
}
if k.RevokedAt != nil {
m["revoked_at"] = k.RevokedAt.UTC().Format(time.RFC3339)
} else {
m["revoked_at"] = nil
}
if k.LastUsedAt != nil {
m["last_used_at"] = k.LastUsedAt.UTC().Format(time.RFC3339)
} else {
m["last_used_at"] = nil
}
return m
}
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
list, err := s.store.ListAPIKeys(a.TenantID)
if err != nil {
writeStoreErr(w, err)
return
}
writePaginatedListJSON(w, r, list, func(k *store.APIKey) map[string]any {
return apiKeyJSON(k)
})
}
func (s *Server) handleGetAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
k, err := s.store.GetAPIKey(a.TenantID, r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, apiKeyJSON(k))
}
func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
var body struct {
Name string `json:"name"`
Role string `json:"role"`
ExpiresAt *string `json:"expires_at"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
in := &store.APIKeyCreate{
Name: strings.TrimSpace(body.Name),
Role: strings.TrimSpace(body.Role),
}
if body.ExpiresAt != nil && strings.TrimSpace(*body.ExpiresAt) != "" {
t, err := time.Parse(time.RFC3339, strings.TrimSpace(*body.ExpiresAt))
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid expires_at")
return
}
in.ExpiresAt = &t
}
created, err := s.store.CreateAPIKey(a.TenantID, in)
if err != nil {
writeStoreErr(w, err)
return
}
if err := s.keyResolver.Reload(s.store); err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
out := apiKeyJSON(&created.APIKey)
out["token"] = created.Token
writeJSON(w, http.StatusCreated, out)
}
func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
patch := &store.APIKeyPatch{}
if v, ok := raw["name"]; ok {
var name string
if err := json.Unmarshal(v, &name); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid name")
return
}
patch.Name = &name
}
if v, ok := raw["role"]; ok {
var role string
if err := json.Unmarshal(v, &role); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid role")
return
}
patch.Role = &role
}
if v, ok := raw["expires_at"]; ok {
if string(v) == "null" {
patch.ClearExpiresAt = true
} else {
var s string
if err := json.Unmarshal(v, &s); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid expires_at")
return
}
t, err := time.Parse(time.RFC3339, strings.TrimSpace(s))
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid expires_at")
return
}
patch.ExpiresAt = &t
}
}
k, err := s.store.UpdateAPIKey(a.TenantID, r.PathValue("id"), patch)
if err != nil {
writeStoreErr(w, err)
return
}
if err := s.keyResolver.Reload(s.store); err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
writeJSON(w, http.StatusOK, apiKeyJSON(k))
}
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
if err := s.store.RevokeAPIKey(a.TenantID, r.PathValue("id")); err != nil {
writeStoreErr(w, err)
return
}
if err := s.keyResolver.Reload(s.store); err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRotateAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
rotated, err := s.store.RotateAPIKey(a.TenantID, r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
}
if err := s.keyResolver.Reload(s.store); err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
out := apiKeyJSON(&rotated.APIKey)
out["token"] = rotated.Token
writeJSON(w, http.StatusOK, out)
}
+119
View File
@@ -0,0 +1,119 @@
package httpapi
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestBearerDevWithoutInsecureDev(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules?limit=1", nil)
req.Header.Set("Authorization", "Bearer dev")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
}
}
func TestAPIKeysCRUDAndAuth(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
base := ts.URL
reqCreate, _ := http.NewRequest(http.MethodPost, base+"/v1/api-keys", strings.NewReader(`{"name":"ci","role":"editor"}`))
reqCreate.Header.Set("Authorization", "Bearer opkey")
reqCreate.Header.Set("Content-Type", "application/json")
respCreate, err := client.Do(reqCreate)
if err != nil {
t.Fatal(err)
}
defer respCreate.Body.Close()
if respCreate.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(respCreate.Body)
t.Fatalf("create status=%d body=%s", respCreate.StatusCode, b)
}
var created map[string]any
if err := json.NewDecoder(respCreate.Body).Decode(&created); err != nil {
t.Fatal(err)
}
token, _ := created["token"].(string)
if token == "" {
t.Fatal("missing token in create response")
}
id, _ := created["id"].(string)
if id == "" {
t.Fatal("missing id")
}
reqMod, _ := http.NewRequest(http.MethodGet, base+"/v1/modules?limit=1", nil)
reqMod.Header.Set("Authorization", "Bearer "+token)
respMod, err := client.Do(reqMod)
if err != nil {
t.Fatal(err)
}
defer respMod.Body.Close()
if respMod.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respMod.Body)
t.Fatalf("modules status=%d body=%s", respMod.StatusCode, b)
}
reqDel, _ := http.NewRequest(http.MethodDelete, base+"/v1/api-keys/"+id, nil)
reqDel.Header.Set("Authorization", "Bearer opkey")
respDel, err := client.Do(reqDel)
if err != nil {
t.Fatal(err)
}
defer respDel.Body.Close()
if respDel.StatusCode != http.StatusNoContent {
t.Fatalf("delete status=%d", respDel.StatusCode)
}
reqAfter, _ := http.NewRequest(http.MethodGet, base+"/v1/modules?limit=1", nil)
reqAfter.Header.Set("Authorization", "Bearer "+token)
respAfter, err := client.Do(reqAfter)
if err != nil {
t.Fatal(err)
}
defer respAfter.Body.Close()
if respAfter.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 after revoke, got %d", respAfter.StatusCode)
}
mustSetTestAPIKeys(t, srv, "nodekey|"+tenant+"|node,opkey|"+tenant+"|operator")
reqNode2, _ := http.NewRequest(http.MethodGet, base+"/v1/api-keys", nil)
reqNode2.Header.Set("Authorization", "Bearer nodekey")
respNode, err := client.Do(reqNode2)
if err != nil {
t.Fatal(err)
}
defer respNode.Body.Close()
if respNode.StatusCode != http.StatusForbidden {
t.Fatalf("node list api-keys status=%d want 403", respNode.StatusCode)
}
}
+2
View File
@@ -70,6 +70,8 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
m.HandleFunc("GET /settings", s.handleGetSettings)
m.HandleFunc("PATCH /settings", s.handlePatchSettings)
s.registerAPIKeyRoutes(m)
}
func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -21,7 +21,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
defer srv.Close()
tenant, _, modIP, _, _ := srv.Store().DemoIDs()
srv.apiKeys = parseAPIKeysSpec("opkey|" + tenant + "|operator")
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
+1 -1
View File
@@ -17,7 +17,7 @@ func TestNestedModuleListPagination(t *testing.T) {
}
defer srv.Close()
tenant, _, modIP, _, _ := srv.Store().DemoIDs()
srv.apiKeys = parseAPIKeysSpec("edkey|" + tenant + "|editor")
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
+6 -4
View File
@@ -21,8 +21,7 @@ type Server struct {
pgPool *pgxpool.Pool
jobs *jobs.Registry
bundlePriv ed25519.PrivateKey
apiKeys []apiKeyRecord
insecureDev bool
keyResolver *apiKeyResolver
corsOrigins []string
cdnHTTP *http.Client
mux *http.ServeMux
@@ -60,13 +59,16 @@ func New(opts Options) (*Server, error) {
_, priv, _ = ed25519.GenerateKey(rand.Reader)
}
resolver, err := newAPIKeyResolver(opts.APIKeys, backend)
if err != nil {
return nil, err
}
s := &Server{
store: backend,
pgPool: pool,
jobs: reg,
bundlePriv: priv,
apiKeys: parseAPIKeysSpec(opts.APIKeys),
insecureDev: opts.InsecureDev && opts.SeedDemo,
keyResolver: resolver,
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
cdnHTTP: NewCDNHTTPClient(),
}
+1 -1
View File
@@ -29,7 +29,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
}
defer srv.Close()
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator,edkey|" + tenant + "|editor")
mustSetTestAPIKeys(t, srv, "nodekey|"+tenant+"|node,opkey|"+tenant+"|operator,edkey|"+tenant+"|editor")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
+12
View File
@@ -0,0 +1,12 @@
package httpapi
import "testing"
func mustSetTestAPIKeys(t *testing.T, srv *Server, spec string) {
t.Helper()
resolver, err := newAPIKeyResolver(spec, srv.store)
if err != nil {
t.Fatal(err)
}
srv.keyResolver = resolver
}
+203
View File
@@ -0,0 +1,203 @@
package repository
import (
"context"
"errors"
"strings"
"time"
"evobgp/internal/authkey"
"evobgp/internal/store"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func (p *Postgres) ListAPIKeys(tenantID string) ([]*store.APIKey, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id::text, name, role, token_prefix, created_at, updated_at, expires_at, revoked_at, last_used_at
FROM api_key WHERE tenant_id=$1 ORDER BY created_at DESC`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*store.APIKey
for rows.Next() {
k, err := scanAPIKeyRow(rows.Scan, tenantID)
if err != nil {
return nil, err
}
out = append(out, k)
}
return out, rows.Err()
}
func (p *Postgres) GetAPIKey(tenantID, id string) (*store.APIKey, error) {
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT id::text, name, role, token_prefix, created_at, updated_at, expires_at, revoked_at, last_used_at
FROM api_key WHERE id=$1 AND tenant_id=$2`, id, tenantID)
k, err := scanAPIKeyRow(row.Scan, tenantID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
}
return nil, err
}
return k, nil
}
func (p *Postgres) CreateAPIKey(tenantID string, in *store.APIKeyCreate) (*store.APIKeyWithSecret, error) {
if in == nil || strings.TrimSpace(in.Name) == "" || !store.ValidAPIKeyRole(in.Role) {
return nil, store.ErrInvalidInput
}
tok, err := authkey.GenerateToken()
if err != nil {
return nil, err
}
id := uuid.NewString()
hash := authkey.HashToken(tok)
prefix := authkey.Prefix(tok)
role := strings.ToLower(strings.TrimSpace(in.Role))
ctx := context.Background()
_, err = p.pool.Exec(ctx, `
INSERT INTO api_key (id, tenant_id, name, role, token_prefix, token_hash, expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
id, tenantID, strings.TrimSpace(in.Name), role, prefix, hash, in.ExpiresAt)
if err != nil {
return nil, err
}
k, err := p.GetAPIKey(tenantID, id)
if err != nil {
return nil, err
}
return &store.APIKeyWithSecret{APIKey: *k, Token: tok}, nil
}
func (p *Postgres) UpdateAPIKey(tenantID, id string, patch *store.APIKeyPatch) (*store.APIKey, error) {
cur, err := p.GetAPIKey(tenantID, id)
if err != nil {
return nil, err
}
if cur.RevokedAt != nil {
return nil, store.ErrInvalidInput
}
if patch == nil {
return nil, store.ErrInvalidInput
}
if patch.Name != nil {
n := strings.TrimSpace(*patch.Name)
if n == "" {
return nil, store.ErrInvalidInput
}
cur.Name = n
}
if patch.Role != nil {
if !store.ValidAPIKeyRole(*patch.Role) {
return nil, store.ErrInvalidInput
}
cur.Role = strings.ToLower(strings.TrimSpace(*patch.Role))
}
if patch.ClearExpiresAt {
cur.ExpiresAt = nil
} else if patch.ExpiresAt != nil {
cur.ExpiresAt = patch.ExpiresAt
}
ctx := context.Background()
_, err = p.pool.Exec(ctx, `
UPDATE api_key SET name=$3, role=$4, expires_at=$5, updated_at=now()
WHERE id=$1 AND tenant_id=$2 AND revoked_at IS NULL`,
id, tenantID, cur.Name, cur.Role, cur.ExpiresAt)
if err != nil {
return nil, err
}
return p.GetAPIKey(tenantID, id)
}
func (p *Postgres) RevokeAPIKey(tenantID, id string) error {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `
UPDATE api_key SET revoked_at=now(), updated_at=now()
WHERE id=$1 AND tenant_id=$2 AND revoked_at IS NULL`, id, tenantID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return store.ErrNotFound
}
return nil
}
func (p *Postgres) RotateAPIKey(tenantID, id string) (*store.APIKeyWithSecret, error) {
cur, err := p.GetAPIKey(tenantID, id)
if err != nil {
return nil, err
}
if cur.RevokedAt != nil {
return nil, store.ErrInvalidInput
}
tok, err := authkey.GenerateToken()
if err != nil {
return nil, err
}
hash := authkey.HashToken(tok)
prefix := authkey.Prefix(tok)
ctx := context.Background()
_, err = p.pool.Exec(ctx, `
UPDATE api_key SET token_hash=$3, token_prefix=$4, updated_at=now()
WHERE id=$1 AND tenant_id=$2 AND revoked_at IS NULL`,
id, tenantID, hash, prefix)
if err != nil {
return nil, err
}
k, err := p.GetAPIKey(tenantID, id)
if err != nil {
return nil, err
}
return &store.APIKeyWithSecret{APIKey: *k, Token: tok}, nil
}
func (p *Postgres) ListActiveAPIKeyHashes() ([]store.APIKeyAuthRow, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id::text, tenant_id::text, role, token_hash
FROM api_key
WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []store.APIKeyAuthRow
for rows.Next() {
var row store.APIKeyAuthRow
var hash []byte
if err := rows.Scan(&row.ID, &row.TenantID, &row.Role, &hash); err != nil {
return nil, err
}
row.TokenHash = append([]byte(nil), hash...)
out = append(out, row)
}
return out, rows.Err()
}
func (p *Postgres) TouchAPIKeyLastUsed(id string) error {
ctx := context.Background()
_, err := p.pool.Exec(ctx, `UPDATE api_key SET last_used_at=now() WHERE id=$1`, id)
return err
}
type scanFn func(dest ...any) error
func scanAPIKeyRow(scan scanFn, tenantID string) (*store.APIKey, error) {
var k store.APIKey
k.TenantID = tenantID
var expires, revoked, lastUsed *time.Time
if err := scan(&k.ID, &k.Name, &k.Role, &k.Prefix, &k.CreatedAt, &k.UpdatedAt, &expires, &revoked, &lastUsed); err != nil {
return nil, err
}
k.ExpiresAt = expires
k.RevokedAt = revoked
k.LastUsedAt = lastUsed
return &k, nil
}
+63
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"strings"
"time"
)
@@ -88,6 +89,15 @@ type Backend interface {
ListGlobalSettings(tenantID string) (map[string]any, error)
PatchGlobalSettings(tenantID string, patch map[string]any) error
ListAPIKeys(tenantID string) ([]*APIKey, error)
GetAPIKey(tenantID, id string) (*APIKey, error)
CreateAPIKey(tenantID string, in *APIKeyCreate) (*APIKeyWithSecret, error)
UpdateAPIKey(tenantID, id string, patch *APIKeyPatch) (*APIKey, error)
RevokeAPIKey(tenantID, id string) error
RotateAPIKey(tenantID, id string) (*APIKeyWithSecret, error)
ListActiveAPIKeyHashes() ([]APIKeyAuthRow, error)
TouchAPIKeyLastUsed(id string) error
// Module prefix snapshots cache last successful collect per module (pipeline ingest/render).
GetModulePrefixSnapshot(tenantID, moduleID string) (*ModulePrefixSnapshot, bool, error)
SetModulePrefixSnapshot(tenantID, moduleID, inputHash string, prefixes []PrefixRow) error
@@ -227,6 +237,59 @@ type CommunityPatch struct {
ValueJSON *string `json:"value_json,omitempty"`
}
// APIKey is tenant-scoped API key metadata (secret never stored in plaintext).
type APIKey struct {
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
Name string `json:"name"`
Role string `json:"role"`
Prefix string `json:"prefix"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
}
// APIKeyCreate is input for issuing a new key.
type APIKeyCreate struct {
Name string `json:"name"`
Role string `json:"role"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// APIKeyPatch is a partial update (role change affects auth after resolver reload).
type APIKeyPatch struct {
Name *string `json:"name,omitempty"`
Role *string `json:"role,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
ClearExpiresAt bool `json:"-"`
}
// APIKeyWithSecret is returned only on create/rotate.
type APIKeyWithSecret struct {
APIKey
Token string `json:"token"`
}
// APIKeyAuthRow is used to build the in-process auth index.
type APIKeyAuthRow struct {
ID string
TenantID string
Role string
TokenHash []byte
}
// ValidAPIKeyRole reports whether role is allowed for API keys.
func ValidAPIKeyRole(role string) bool {
switch strings.ToLower(strings.TrimSpace(role)) {
case "viewer", "editor", "operator", "node":
return true
default:
return false
}
}
type PeerPatch struct {
Neighbor *string `json:"neighbor,omitempty"`
RemoteASN *int64 `json:"remote_asn,omitempty"`
+7
View File
@@ -43,6 +43,7 @@ type Memory struct {
revPrefixes map[string][]PrefixRow
moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry
apiKeys map[string]*apiKeyRec
// DemoIDs valid after SeedDemo()
demoTenantID string
@@ -57,6 +58,11 @@ type publishedInfo struct {
PublishedAt time.Time
}
type apiKeyRec struct {
APIKey
TokenHash []byte
}
type Tenant struct {
ID string
Name string
@@ -133,6 +139,7 @@ func NewMemory() *Memory {
revPrefixes: make(map[string][]PrefixRow),
moduleSnapshots: make(map[string]*moduleSnapshotRec),
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
apiKeys: make(map[string]*apiKeyRec),
}
}
+184
View File
@@ -0,0 +1,184 @@
package store
import (
"strings"
"time"
"evobgp/internal/authkey"
"github.com/google/uuid"
)
func (m *Memory) ListAPIKeys(tenantID string) ([]*APIKey, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var out []*APIKey
for _, rec := range m.apiKeys {
if rec.TenantID == tenantID {
out = append(out, apiKeyCopy(&rec.APIKey))
}
}
return out, nil
}
func (m *Memory) GetAPIKey(tenantID, id string) (*APIKey, error) {
m.mu.RLock()
defer m.mu.RUnlock()
rec, ok := m.apiKeys[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
return apiKeyCopy(&rec.APIKey), nil
}
func (m *Memory) CreateAPIKey(tenantID string, in *APIKeyCreate) (*APIKeyWithSecret, error) {
if in == nil || strings.TrimSpace(in.Name) == "" || !ValidAPIKeyRole(in.Role) {
return nil, ErrInvalidInput
}
tok, err := authkey.GenerateToken()
if err != nil {
return nil, err
}
now := time.Now().UTC()
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.tenants[tenantID]; !ok {
return nil, ErrTenantScope
}
id := uuid.NewString()
k := &apiKeyRec{
APIKey: APIKey{
ID: id,
TenantID: tenantID,
Name: strings.TrimSpace(in.Name),
Role: strings.ToLower(strings.TrimSpace(in.Role)),
Prefix: authkey.Prefix(tok),
CreatedAt: now,
UpdatedAt: now,
ExpiresAt: in.ExpiresAt,
},
TokenHash: authkey.HashToken(tok),
}
m.apiKeys[id] = k
return &APIKeyWithSecret{APIKey: *apiKeyCopy(&k.APIKey), Token: tok}, nil
}
func (m *Memory) UpdateAPIKey(tenantID, id string, patch *APIKeyPatch) (*APIKey, error) {
if patch == nil {
return nil, ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.apiKeys[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
if rec.RevokedAt != nil {
return nil, ErrInvalidInput
}
if patch.Name != nil {
n := strings.TrimSpace(*patch.Name)
if n == "" {
return nil, ErrInvalidInput
}
rec.Name = n
}
if patch.Role != nil {
if !ValidAPIKeyRole(*patch.Role) {
return nil, ErrInvalidInput
}
rec.Role = strings.ToLower(strings.TrimSpace(*patch.Role))
}
if patch.ClearExpiresAt {
rec.ExpiresAt = nil
} else if patch.ExpiresAt != nil {
rec.ExpiresAt = patch.ExpiresAt
}
rec.UpdatedAt = time.Now().UTC()
return apiKeyCopy(&rec.APIKey), nil
}
func (m *Memory) RevokeAPIKey(tenantID, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.apiKeys[id]
if !ok || rec.TenantID != tenantID {
return ErrNotFound
}
now := time.Now().UTC()
rec.RevokedAt = &now
rec.UpdatedAt = now
return nil
}
func (m *Memory) RotateAPIKey(tenantID, id string) (*APIKeyWithSecret, error) {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.apiKeys[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
if rec.RevokedAt != nil {
return nil, ErrInvalidInput
}
tok, err := authkey.GenerateToken()
if err != nil {
return nil, err
}
now := time.Now().UTC()
rec.TokenHash = authkey.HashToken(tok)
rec.Prefix = authkey.Prefix(tok)
rec.UpdatedAt = now
return &APIKeyWithSecret{APIKey: *apiKeyCopy(&rec.APIKey), Token: tok}, nil
}
func (m *Memory) ListActiveAPIKeyHashes() ([]APIKeyAuthRow, error) {
m.mu.RLock()
defer m.mu.RUnlock()
now := time.Now().UTC()
var out []APIKeyAuthRow
for _, rec := range m.apiKeys {
if rec.RevokedAt != nil {
continue
}
if rec.ExpiresAt != nil && !rec.ExpiresAt.After(now) {
continue
}
out = append(out, APIKeyAuthRow{
ID: rec.ID,
TenantID: rec.TenantID,
Role: rec.Role,
TokenHash: append([]byte(nil), rec.TokenHash...),
})
}
return out, nil
}
func (m *Memory) TouchAPIKeyLastUsed(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.apiKeys[id]
if !ok {
return ErrNotFound
}
now := time.Now().UTC()
rec.LastUsedAt = &now
return nil
}
func apiKeyCopy(k *APIKey) *APIKey {
cp := *k
if k.ExpiresAt != nil {
t := *k.ExpiresAt
cp.ExpiresAt = &t
}
if k.RevokedAt != nil {
t := *k.RevokedAt
cp.RevokedAt = &t
}
if k.LastUsedAt != nil {
t := *k.LastUsedAt
cp.LastUsedAt = &t
}
return &cp
}
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_api_key_tenant_active;
DROP INDEX IF EXISTS idx_api_key_token_hash;
DROP TABLE IF EXISTS api_key;
+19
View File
@@ -0,0 +1,19 @@
CREATE TABLE api_key (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT NOT NULL,
token_prefix TEXT NOT NULL,
token_hash BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
CONSTRAINT api_key_role_chk CHECK (role IN ('viewer', 'editor', 'operator', 'node')),
CONSTRAINT api_key_name_chk CHECK (length(trim(name)) > 0),
CONSTRAINT api_key_token_hash_len_chk CHECK (octet_length(token_hash) = 32)
);
CREATE UNIQUE INDEX idx_api_key_token_hash ON api_key (token_hash);
CREATE INDEX idx_api_key_tenant_active ON api_key (tenant_id) WHERE revoked_at IS NULL;
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_api_key_tenant_active;
DROP INDEX IF EXISTS idx_api_key_token_hash;
DROP TABLE IF EXISTS api_key;
+19
View File
@@ -0,0 +1,19 @@
CREATE TABLE api_key (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT NOT NULL,
token_prefix TEXT NOT NULL,
token_hash BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
expires_at TEXT,
revoked_at TEXT,
last_used_at TEXT,
CHECK (role IN ('viewer', 'editor', 'operator', 'node')),
CHECK (length(trim(name)) > 0),
CHECK (length(token_hash) = 32)
);
CREATE UNIQUE INDEX idx_api_key_token_hash ON api_key (token_hash);
CREATE INDEX idx_api_key_tenant_active ON api_key (tenant_id) WHERE revoked_at IS NULL;
+30
View File
@@ -253,3 +253,33 @@ export type JobsResponse = Page<JobRow>;
// ---- Settings ----
export type AppSettings = Record<string, unknown>;
// ---- Auth / API keys ----
export type AuthSession = {
tenant_id: string;
role: 'viewer' | 'editor' | 'operator' | 'node';
};
export type ApiKeyRole = AuthSession['role'];
export type ApiKey = {
id: string;
name: string;
role: ApiKeyRole;
prefix: string;
created_at: string;
updated_at: string;
expires_at: string | null;
revoked_at: string | null;
last_used_at: string | null;
};
export type ApiKeysResponse = Page<ApiKey>;
export type ApiKeyCreate = {
name: string;
role: ApiKeyRole;
expires_at?: string | null;
};
export type ApiKeyCreated = ApiKey & { token: string };
@@ -0,0 +1,279 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '$lib/api/types.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/ui/core/card/index.js';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Trash2 from '@lucide/svelte/icons/trash-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Copy from '@lucide/svelte/icons/copy';
type Props = {
items: ApiKey[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
const roleOptions: Array<{ value: ApiKeyRole; label: string }> = [
{ value: 'viewer', label: 'viewer — только чтение' },
{ value: 'editor', label: 'editor — CRUD без apply' },
{ value: 'operator', label: 'operator — полный доступ' },
{ value: 'node', label: 'node — только API ноды' }
];
let dialogOpen = $state(false);
let tokenDialogOpen = $state(false);
let revealedToken = $state('');
let form = $state<ApiKeyCreate>({ name: '', role: 'editor' });
let expiresLocal = $state('');
let saving = $state(false);
const columns = [
{ id: 'name', label: 'Имя', sortable: true, sortValue: (k: ApiKey) => k.name },
{ id: 'role', label: 'Роль', sortable: true, sortValue: (k: ApiKey) => k.role },
{ id: 'prefix', label: 'Префикс', sortable: true, sortValue: (k: ApiKey) => k.prefix },
{
id: 'revoked',
label: 'Статус',
sortable: true,
sortValue: (k: ApiKey) => (k.revoked_at ? 1 : 0)
},
{ id: 'actions', label: '', class: 'w-24' }
] as const;
function openCreate() {
form = { name: '', role: 'editor' };
expiresLocal = '';
dialogOpen = true;
}
function showToken(created: ApiKeyCreated) {
revealedToken = created.token;
tokenDialogOpen = true;
}
async function copyToken() {
try {
await navigator.clipboard.writeText(revealedToken);
notify.success('Скопировано');
} catch {
notify.error('Не удалось скопировать');
}
}
function requestRevoke(k: ApiKey) {
if (k.revoked_at) return;
void confirm({
title: 'Отозвать API-ключ?',
description: `${k.name} (${k.prefix}…)`,
confirmLabel: 'Отозвать',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/api-keys/${k.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Ключ отозван');
await onRefresh();
}
});
}
function requestRotate(k: ApiKey) {
if (k.revoked_at) return;
void confirm({
title: 'Ротировать ключ?',
description: 'Старый токен перестанет работать сразу.',
confirmLabel: 'Ротировать',
onConfirm: async () => {
try {
const out = await apiMutate<ApiKeyCreated>(
`/v1/api-keys/${k.id}/rotate`,
'POST',
undefined,
{ idempotent: false }
);
notify.success('Ключ обновлён');
showToken(out);
await onRefresh();
} catch (e) {
notifyApiError(e);
}
}
});
}
async function save() {
if (!form.name.trim()) {
notify.error('Укажите имя');
return;
}
saving = true;
try {
const body: ApiKeyCreate = {
name: form.name.trim(),
role: form.role
};
if (expiresLocal.trim()) {
const d = new Date(expiresLocal);
if (Number.isNaN(d.getTime())) {
notify.error('Некорректная дата истечения');
return;
}
body.expires_at = d.toISOString();
}
const created = await apiMutate<ApiKeyCreated>('/v1/api-keys', 'POST', body);
notify.success('Ключ создан');
dialogOpen = false;
showToken(created);
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">API-ключи</CardTitle>
<CardDescription>
Управление ключами tenant. Полный токен показывается только при создании и ротации.
</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" variant="outline" onclick={() => onRefresh()} disabled={loading}>
<RefreshCw class={loading ? 'animate-spin' : ''} />
Обновить
</Button>
<Button size="sm" onclick={openCreate}><Plus />Создать</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(k) => k.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет ключей"
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
>
{#snippet cell({ row: k, column })}
{#if column.id === 'name'}
<span class="font-medium">{k.name}</span>
{:else if column.id === 'role'}
<span class="font-mono text-sm">{k.role}</span>
{:else if column.id === 'prefix'}
<span class="font-mono text-xs text-muted-foreground">{k.prefix}</span>
{:else if column.id === 'revoked'}
{#if k.revoked_at}
<span class="text-sm text-destructive">отозван</span>
{:else}
<span class="text-sm text-muted-foreground">активен</span>
{/if}
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button
variant="ghost"
size="icon-sm"
title="Ротировать"
disabled={!!k.revoked_at}
onclick={() => requestRotate(k)}
>
<RefreshCw class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
disabled={!!k.revoked_at}
onclick={() => requestRevoke(k)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Новый API-ключ</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<FormField label="Имя" id="key-name" required>
<AppInput id="key-name" bind:value={form.name} placeholder="CI / оператор UI" />
</FormField>
<FormField label="Роль" id="key-role" required>
<Select
type="single"
value={form.role}
onValueChange={(v) => (form.role = v as ApiKeyRole)}
>
<SelectTrigger id="key-role" class="w-full">
{roleOptions.find((o) => o.value === form.role)?.label ?? form.role}
</SelectTrigger>
<SelectContent>
{#each roleOptions as opt (opt.value)}
<SelectItem value={opt.value} label={opt.label}>{opt.label}</SelectItem>
{/each}
</SelectContent>
</Select>
</FormField>
<FormField label="Истекает (опционально)" id="key-expires">
<AppInput id="key-expires" type="datetime-local" bind:value={expiresLocal} />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Создание…' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={tokenDialogOpen}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Сохраните токен</DialogTitle>
<DialogDescription
>Он больше не будет показан. Скопируйте в безопасное хранилище.</DialogDescription
>
</DialogHeader>
<div class="rounded-md border bg-muted/40 p-3 font-mono text-xs break-all">{revealedToken}</div>
<DialogFooter>
<Button variant="outline" onclick={copyToken}><Copy />Копировать</Button>
<Button onclick={() => (tokenDialogOpen = false)}>Готово</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+5 -1
View File
@@ -7,6 +7,7 @@ import Gauge from '@lucide/svelte/icons/gauge';
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
import Network from '@lucide/svelte/icons/network';
import Settings from '@lucide/svelte/icons/settings';
import Shield from '@lucide/svelte/icons/shield';
export type NavItem = {
href: string;
label: string;
@@ -23,4 +24,7 @@ export const mainNav: NavItem[] = [
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge }
];
export const bottomNav: NavItem[] = [{ href: '/settings', label: 'Настройки', icon: Settings }];
export const bottomNav: NavItem[] = [
{ href: '/access', label: 'Права доступа', icon: Shield },
{ href: '/settings', label: 'Настройки', icon: Settings }
];
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import { apiJSON } from '$lib/api/client.js';
import type { ApiKey, ApiKeysResponse, AuthSession } from '$lib/api/types.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/ui/core/card/index.js';
import { notifyApiError } from '$lib/ui/app/toast.js';
import AccessApiKeysCard from '$lib/components/access/AccessApiKeysCard.svelte';
import Shield from '@lucide/svelte/icons/shield';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
let session = $state<AuthSession | null>(null);
let apiKeys = $state<ApiKey[]>([]);
let keysLoading = $state(false);
let keysInitial = $state(true);
let keysError = $state<string | null>(null);
const isOperator = $derived(session?.role === 'operator');
async function loadSession() {
try {
session = await apiJSON<AuthSession>('/v1/auth/session');
} catch {
session = null;
}
}
async function loadApiKeys() {
keysLoading = true;
keysError = null;
try {
const page = await apiJSON<ApiKeysResponse>('/v1/api-keys?limit=500');
apiKeys = page.items ?? [];
} catch (e) {
apiKeys = [];
keysError = e instanceof Error ? e.message : 'Ошибка загрузки';
notifyApiError(e);
} finally {
keysLoading = false;
keysInitial = false;
}
}
onMount(() => {
void (async () => {
await loadSession();
if (session?.role === 'operator') await loadApiKeys();
else keysInitial = false;
})();
});
</script>
<div class="mx-auto flex max-w-4xl flex-col gap-6">
<PageHeader
title="Права доступа"
description="API-ключи control plane и текущая сессия Bearer-токена."
icon={Shield}
iconClass="bg-primary/10 text-primary"
/>
{#if session}
<Card>
<CardHeader>
<CardTitle class="text-base">Текущая сессия</CardTitle>
<CardDescription>Tenant и роль ключа, с которым открыта панель.</CardDescription>
</CardHeader>
<CardContent class="grid gap-3 text-sm sm:grid-cols-2">
<div>
<p class="text-muted-foreground">Tenant</p>
<p class="font-mono text-xs break-all">{session.tenant_id}</p>
</div>
<div>
<p class="text-muted-foreground">Роль</p>
<p class="font-mono">{session.role}</p>
</div>
</CardContent>
</Card>
{/if}
{#if isOperator}
<AccessApiKeysCard
items={apiKeys}
loading={keysLoading}
initialLoading={keysInitial}
error={keysError}
onRefresh={loadApiKeys}
/>
{:else if session}
<Card>
<CardContent class="py-6 text-sm text-muted-foreground">
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:
<span class="font-mono">{session.role}</span>. Для выдачи ключей войдите с operator-ключом
или создайте ключ через API / переменную <code class="text-xs">EVOBGP_API_KEYS</code>.
</CardContent>
</Card>
{:else}
<Card>
<CardContent class="py-6 text-sm text-muted-foreground">
Не удалось определить сессию. Укажите Bearer-токен в
<a href={resolve('/settings')} class="text-primary underline-offset-4 hover:underline"
>настройках</a
>
интерфейса.
</CardContent>
</Card>
{/if}
</div>
+8 -6
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { browser } from '$app/environment';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import { TOKEN_STORAGE_KEY } from '$lib/api/client.js';
import { themeState } from '$lib/theme-preferences.svelte.js';
@@ -53,23 +54,24 @@
<div class="mx-auto flex max-w-3xl flex-col gap-6">
<PageHeader
title="Настройки"
description="Параметры браузера и подключения к API."
description="Параметры интерфейса и подключения браузера к API."
icon={SettingsIcon}
iconClass="bg-muted text-muted-foreground"
/>
<Card>
<CardHeader>
<CardTitle>API-ключ</CardTitle>
<CardTitle>Подключение к API</CardTitle>
<CardDescription>
Bearer-токен хранится только в localStorage браузера. Для локального демо с
<code class="text-xs">EVOBGP_DEV_INSECURE=1</code> используйте токен
<code class="text-xs">dev</code>.
Bearer-токен хранится только в этом браузере (localStorage). Управление ключами tenant — в
разделе <a href={resolve('/access')} class="text-primary underline-offset-4 hover:underline"
>Права доступа</a
>.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="token">Токен</Label>
<Label for="token">Токен для запросов</Label>
<Input
id="token"
type="password"