diff --git a/cmd/evobgp-api/main.go b/cmd/evobgp-api/main.go index 1f67f5b..91f2683 100644 --- a/cmd/evobgp-api/main.go +++ b/cmd/evobgp-api/main.go @@ -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) diff --git a/docs/access.md b/docs/access.md index 20143f4..abd8de8 100644 --- a/docs/access.md +++ b/docs/access.md @@ -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. diff --git a/docs/api.md b/docs/api.md index 6ae31cb..e2acfcb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 467faa1..662b2b8 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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] diff --git a/internal/authkey/token.go b/internal/authkey/token.go new file mode 100644 index 0000000..cf3fce3 --- /dev/null +++ b/internal/authkey/token.go @@ -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] +} diff --git a/internal/httpapi/api_key_resolver.go b/internal/httpapi/api_key_resolver.go new file mode 100644 index 0000000..c954337 --- /dev/null +++ b/internal/httpapi/api_key_resolver.go @@ -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 +} diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index e395b9f..00b5abd 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -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) }) diff --git a/internal/httpapi/routes_api_keys.go b/internal/httpapi/routes_api_keys.go new file mode 100644 index 0000000..1743778 --- /dev/null +++ b/internal/httpapi/routes_api_keys.go @@ -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) +} diff --git a/internal/httpapi/routes_api_keys_test.go b/internal/httpapi/routes_api_keys_test.go new file mode 100644 index 0000000..24a3b50 --- /dev/null +++ b/internal/httpapi/routes_api_keys_test.go @@ -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) + } +} diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index 50adeaf..a12405b 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -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) { diff --git a/internal/httpapi/routes_crud_csv_test.go b/internal/httpapi/routes_crud_csv_test.go index 9750d6f..a397a11 100644 --- a/internal/httpapi/routes_crud_csv_test.go +++ b/internal/httpapi/routes_crud_csv_test.go @@ -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() diff --git a/internal/httpapi/routes_crud_list_test.go b/internal/httpapi/routes_crud_list_test.go index 53ae9a8..9a78305 100644 --- a/internal/httpapi/routes_crud_list_test.go +++ b/internal/httpapi/routes_crud_list_test.go @@ -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() diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 76b7803..6a80799 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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(), } diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 7a9f993..8a927a6 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -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() diff --git a/internal/httpapi/test_helpers.go b/internal/httpapi/test_helpers.go new file mode 100644 index 0000000..8cfa97e --- /dev/null +++ b/internal/httpapi/test_helpers.go @@ -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 +} diff --git a/internal/repository/postgres_api_key.go b/internal/repository/postgres_api_key.go new file mode 100644 index 0000000..ed7cb09 --- /dev/null +++ b/internal/repository/postgres_api_key.go @@ -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 +} diff --git a/internal/store/backend.go b/internal/store/backend.go index 505a76c..0d17680 100644 --- a/internal/store/backend.go +++ b/internal/store/backend.go @@ -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"` diff --git a/internal/store/memory.go b/internal/store/memory.go index 02c2f63..7c16d53 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -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), } } diff --git a/internal/store/memory_api_key.go b/internal/store/memory_api_key.go new file mode 100644 index 0000000..f97df5c --- /dev/null +++ b/internal/store/memory_api_key.go @@ -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 +} diff --git a/migrations/postgres/000013_api_key.down.sql b/migrations/postgres/000013_api_key.down.sql new file mode 100644 index 0000000..6b1233a --- /dev/null +++ b/migrations/postgres/000013_api_key.down.sql @@ -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; diff --git a/migrations/postgres/000013_api_key.up.sql b/migrations/postgres/000013_api_key.up.sql new file mode 100644 index 0000000..60638a3 --- /dev/null +++ b/migrations/postgres/000013_api_key.up.sql @@ -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; diff --git a/migrations/sqlite/000013_api_key.down.sql b/migrations/sqlite/000013_api_key.down.sql new file mode 100644 index 0000000..6b1233a --- /dev/null +++ b/migrations/sqlite/000013_api_key.down.sql @@ -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; diff --git a/migrations/sqlite/000013_api_key.up.sql b/migrations/sqlite/000013_api_key.up.sql new file mode 100644 index 0000000..3b51251 --- /dev/null +++ b/migrations/sqlite/000013_api_key.up.sql @@ -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; diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index c14a820..b0560c9 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -253,3 +253,33 @@ export type JobsResponse = Page; // ---- Settings ---- export type AppSettings = Record; + +// ---- 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; + +export type ApiKeyCreate = { + name: string; + role: ApiKeyRole; + expires_at?: string | null; +}; + +export type ApiKeyCreated = ApiKey & { token: string }; diff --git a/web/src/lib/components/access/AccessApiKeysCard.svelte b/web/src/lib/components/access/AccessApiKeysCard.svelte new file mode 100644 index 0000000..b1d594a --- /dev/null +++ b/web/src/lib/components/access/AccessApiKeysCard.svelte @@ -0,0 +1,279 @@ + + + + +
+ API-ключи + + Управление ключами tenant. Полный токен показывается только при создании и ротации. + +
+
+ + +
+
+ + k.id} + loading={initialLoading || loading} + {error} + emptyTitle="Нет ключей" + emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа." + > + {#snippet cell({ row: k, column })} + {#if column.id === 'name'} + {k.name} + {:else if column.id === 'role'} + {k.role} + {:else if column.id === 'prefix'} + {k.prefix}… + {:else if column.id === 'revoked'} + {#if k.revoked_at} + отозван + {:else} + активен + {/if} + {:else if column.id === 'actions'} +
+ + +
+ {/if} + {/snippet} +
+
+
+ + + + + Новый API-ключ + +
+ + + + + + + + + +
+ + + + +
+
+ + + + + Сохраните токен + Он больше не будет показан. Скопируйте в безопасное хранилище. + +
{revealedToken}
+ + + + +
+
diff --git a/web/src/lib/ui/app/layout/nav.ts b/web/src/lib/ui/app/layout/nav.ts index e7a9e7a..d45a314 100644 --- a/web/src/lib/ui/app/layout/nav.ts +++ b/web/src/lib/ui/app/layout/nav.ts @@ -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 } +]; diff --git a/web/src/routes/access/+page.svelte b/web/src/routes/access/+page.svelte new file mode 100644 index 0000000..388efd0 --- /dev/null +++ b/web/src/routes/access/+page.svelte @@ -0,0 +1,113 @@ + + +
+ + + {#if session} + + + Текущая сессия + Tenant и роль ключа, с которым открыта панель. + + +
+

Tenant

+

{session.tenant_id}

+
+
+

Роль

+

{session.role}

+
+
+
+ {/if} + + {#if isOperator} + + {:else if session} + + + Управление API-ключами доступно только роли operator. Текущая роль: + {session.role}. Для выдачи ключей войдите с operator-ключом + или создайте ключ через API / переменную EVOBGP_API_KEYS. + + + {:else} + + + Не удалось определить сессию. Укажите Bearer-токен в + настройках + интерфейса. + + + {/if} +
diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 0662a70..27198c9 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -1,5 +1,6 @@