Skip to content

Commit 15586e2

Browse files
authored
feat(manager): add resource and provider management APIs (#21)
* feat(manager): add platform resource APIs with version snapshots (#7) Enable admin CRUD and authenticated listing for platform resources, and persist config snapshots on create/update to support versioned resource management. * feat(manager): secure resource access keys and add provider templates (#7) Remove credential_ref from manager resource contracts, encrypt access_key at rest, and require password re-auth for reveal to tighten sensitive data handling. Add provider_templates CRUD with field schema validation so providers can define dynamic config templates without mixing base_url/access_key into config.
1 parent d2b1115 commit 15586e2

28 files changed

Lines changed: 4319 additions & 21 deletions

cmd/manager/main.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import (
1515
"github.com/liuscraft/orion-x/internal/logging"
1616
"github.com/liuscraft/orion-x/internal/manager/app"
1717
"github.com/liuscraft/orion-x/internal/manager/auth"
18+
"github.com/liuscraft/orion-x/internal/manager/contracts"
1819
"github.com/liuscraft/orion-x/internal/manager/httpapi"
20+
"github.com/liuscraft/orion-x/internal/manager/platformresource"
21+
"github.com/liuscraft/orion-x/internal/manager/providertemplate"
22+
"github.com/liuscraft/orion-x/internal/manager/security"
1923
"github.com/liuscraft/orion-x/internal/manager/storage"
2024
)
2125

@@ -64,13 +68,41 @@ func main() {
6468
authService := auth.NewService(userRepository, authTokenManager)
6569
authHandler := httpapi.NewAuthHandler(authService)
6670
authMiddleware := httpapi.NewAuthMiddleware(authService)
71+
accessKeyCipher, err := security.NewAESCipher(appConfig.Security.AccessKeyCipherSecret)
72+
if err != nil {
73+
logging.Fatalf("Init manager access key cipher failed: %v", err)
74+
}
75+
platformResourceRepository := storage.NewPlatformResourceRepository(store.DB())
76+
platformResourceService := platformresource.NewService(platformResourceRepository, accessKeyCipher)
77+
platformResourceHandler := httpapi.NewPlatformResourceHandler(platformResourceService, authService)
78+
providerTemplateRepository := storage.NewProviderTemplateRepository(store.DB())
79+
providerTemplateService := providertemplate.NewService(providerTemplateRepository)
80+
providerTemplateHandler := httpapi.NewProviderTemplateHandler(providerTemplateService)
6781

6882
router := http.NewServeMux()
6983
router.Handle(appConfig.Server.HealthPath, healthHandler)
7084
router.Handle("/api/v1/auth/register", http.HandlerFunc(authHandler.Register))
7185
router.Handle("/api/v1/auth/login", http.HandlerFunc(authHandler.Login))
7286
router.Handle("/api/v1/auth/refresh", http.HandlerFunc(authHandler.Refresh))
7387
router.Handle("/api/v1/auth/logout", authMiddleware.RequireAuth(http.HandlerFunc(authHandler.Logout)))
88+
router.Handle(
89+
"/api/v1/admin/platform-resources",
90+
authMiddleware.RequireAuth(authMiddleware.RequireRole(contracts.RoleAdmin)(http.HandlerFunc(platformResourceHandler.Create))),
91+
)
92+
router.Handle(
93+
"/api/v1/admin/platform-resources/",
94+
authMiddleware.RequireAuth(authMiddleware.RequireRole(contracts.RoleAdmin)(http.HandlerFunc(platformResourceHandler.ByID))),
95+
)
96+
router.Handle("/api/v1/platform-resources", authMiddleware.RequireAuth(http.HandlerFunc(platformResourceHandler.List)))
97+
router.Handle(
98+
"/api/v1/admin/provider-templates",
99+
authMiddleware.RequireAuth(authMiddleware.RequireRole(contracts.RoleAdmin)(http.HandlerFunc(providerTemplateHandler.Create))),
100+
)
101+
router.Handle(
102+
"/api/v1/admin/provider-templates/",
103+
authMiddleware.RequireAuth(authMiddleware.RequireRole(contracts.RoleAdmin)(http.HandlerFunc(providerTemplateHandler.ByID))),
104+
)
105+
router.Handle("/api/v1/provider-templates", authMiddleware.RequireAuth(http.HandlerFunc(providerTemplateHandler.List)))
74106

75107
server := httpapi.NewServer(appConfig.Server, router)
76108
lifecycle := app.NewLifecycle(appConfig.Migration.AutoMigrateOnStartup, migrator, server)

docs/manager-api-types.md

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,10 @@ const (
128128
| resource_key | text unique | 资源唯一标识 |
129129
| name | text | 展示名 |
130130
| schema_version | int | 配置 schema 版本 |
131+
| base_url | text | 资源请求基地址 |
132+
| access_key | text | 访问密钥密文(数据库不存明文) |
131133
| capabilities | jsonb | 能力标签 |
132134
| config | jsonb | 非敏感配置 |
133-
| credential_ref | text | 密钥引用 |
134135
| status | text | `active/inactive` |
135136
| created_by | uuid | 创建人 |
136137
| created_at | timestamptz | 创建时间 |
@@ -148,8 +149,9 @@ const (
148149
| id | uuid pk | 主键 |
149150
| entry_id | uuid | 关联 `platform_resources.id` |
150151
| version | int | 版本号(从 1 递增) |
152+
| base_url_snapshot | text | `base_url` 快照 |
153+
| access_key_snapshot | text | `access_key` 密文快照 |
151154
| config_snapshot | jsonb | 配置快照 |
152-
| credential_ref_snapshot | text | 凭据引用快照 |
153155
| published_at | timestamptz | 发布时间 |
154156

155157
### 4.4 `tool_market_items`
@@ -287,8 +289,16 @@ const (
287289
- `GET /api/v1/platform-resources?category=...&provider=...&status=...`
288290
- `PATCH /api/v1/admin/platform-resources/:id`
289291
- `DELETE /api/v1/admin/platform-resources/:id`
292+
- `POST /api/v1/admin/platform-resources/:id/access-key/reveal`
290293

291-
### 5.3 工具市场 + 开通 + 用户工具仓库
294+
### 5.3 provider 模板(admin 写,用户读)
295+
296+
- `POST /api/v1/admin/provider-templates`
297+
- `GET /api/v1/provider-templates?category=...&provider=...&status=...`
298+
- `PATCH /api/v1/admin/provider-templates/:id`
299+
- `DELETE /api/v1/admin/provider-templates/:id`
300+
301+
### 5.4 工具市场 + 开通 + 用户工具仓库
292302

293303
- `POST /api/v1/admin/tool-market/items`
294304
- `GET /api/v1/tool-market/items`
@@ -301,7 +311,7 @@ const (
301311
- `GET /api/v1/me/tool-repo/:entitlement_id/usage`
302312
- `POST /api/v1/admin/tool-entitlements/grant`
303313

304-
### 5.4 voicebot + 设备
314+
### 5.5 voicebot + 设备
305315

306316
- `POST /api/v1/voicebots`
307317
- `GET /api/v1/voicebots`
@@ -313,7 +323,7 @@ const (
313323
- `PATCH /api/v1/devices/:id`
314324
- `PUT /api/v1/devices/:device_id/binding`
315325

316-
### 5.5 ws-server 内部解析
326+
### 5.6 ws-server 内部解析
317327

318328
- `GET /internal/v1/devices/:device_id/resolve`
319329

@@ -328,12 +338,12 @@ const (
328338
"resource_key": "llm-zhipu-prod",
329339
"name": "Zhipu Production",
330340
"schema_version": 1,
341+
"base_url": "https://open.bigmodel.cn/api/coding/paas/v4",
342+
"access_key": "sk-prod-xxx",
331343
"capabilities": {"stream": true},
332344
"config": {
333-
"base_url": "https://open.bigmodel.cn/api/coding/paas/v4",
334345
"model": "glm-4-flash"
335-
},
336-
"credential_ref": "secret://manager/llm/zhipu/prod"
346+
}
337347
}
338348
```
339349

docs/voicebot-todo.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,9 @@
181181
- [x] GORM + PostgreSQL 基础接入(连接、迁移、健康检查)
182182
- [x] users/auth 模块(注册 + JWT 登录/刷新 + RBAC)
183183
- [x] web-ui 管理后台骨架(登录态、鉴权拦截、RBAC 菜单)
184-
- [ ] platform_resources 模块(LLM/ASR/TTS 资源管理)
184+
- [x] platform_resources 模块(LLM/ASR/TTS 资源管理)
185+
- [x] provider_templates 模块(模板字段定义 + CRUD)
186+
- [x] platform_resources 安全改造(移除 credential_ref、access_key 加密存储、reveal 二次认证)
185187
- [ ] tool_market 模块(市场、offer、entitlement、tool repo)
186188
- [ ] voicebots/devices/device_bindings 模块
187189
- [ ] internal resolve API(`/internal/v1/devices/:device_id/resolve`

internal/config/manager_config.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type ManagerAppConfig struct {
1616
Logging LoggingConfig `json:"logging"`
1717
Server ManagerServerConfig `json:"server"`
1818
Database ManagerDatabaseConfig `json:"database"`
19+
Security ManagerSecurityConfig `json:"security"`
1920
Auth ManagerAuthConfig `json:"auth"`
2021
Migration ManagerMigrationConfig `json:"migration"`
2122
}
@@ -40,6 +41,10 @@ type ManagerMigrationConfig struct {
4041
AutoMigrateOnStartup bool `json:"auto_migrate_on_startup"`
4142
}
4243

44+
type ManagerSecurityConfig struct {
45+
AccessKeyCipherSecret string `json:"access_key_cipher_secret"`
46+
}
47+
4348
type ManagerAuthConfig struct {
4449
JWTSecret string `json:"jwt_secret"`
4550
Issuer string `json:"issuer"`
@@ -64,6 +69,9 @@ func DefaultManagerConfig() *ManagerAppConfig {
6469
ConnMaxIdleTimeMs: 120000,
6570
PingTimeoutMs: 2000,
6671
},
72+
Security: ManagerSecurityConfig{
73+
AccessKeyCipherSecret: "manager-dev-access-key-cipher-secret",
74+
},
6775
Auth: ManagerAuthConfig{
6876
JWTSecret: "manager-dev-jwt-secret",
6977
Issuer: "orion-x-manager",
@@ -144,6 +152,10 @@ func (c *ManagerAppConfig) ApplyEnv() {
144152
}
145153
}
146154

155+
if secret := strings.TrimSpace(os.Getenv("MANAGER_ACCESS_KEY_CIPHER_SECRET")); secret != "" {
156+
c.Security.AccessKeyCipherSecret = secret
157+
}
158+
147159
if secret := strings.TrimSpace(os.Getenv("MANAGER_AUTH_JWT_SECRET")); secret != "" {
148160
c.Auth.JWTSecret = secret
149161
}
@@ -201,6 +213,10 @@ func (c *ManagerAppConfig) Validate() error {
201213
return errors.New("database.ping_timeout_ms must be > 0")
202214
}
203215

216+
if strings.TrimSpace(c.Security.AccessKeyCipherSecret) == "" {
217+
return errors.New("security.access_key_cipher_secret must not be empty")
218+
}
219+
204220
if strings.TrimSpace(c.Auth.JWTSecret) == "" {
205221
return errors.New("auth.jwt_secret must not be empty")
206222
}

internal/config/manager_config_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func TestLoadManager_MergesDefaultsAndEnv(t *testing.T) {
2121
t.Setenv("MANAGER_SERVER_ADDRESS", "127.0.0.1:9100")
2222
t.Setenv("MANAGER_DB_DSN", "host=127.0.0.1 user=postgres password=postgres dbname=override port=5432 sslmode=disable")
2323
t.Setenv("MANAGER_DB_MAX_OPEN_CONNS", "25")
24+
t.Setenv("MANAGER_ACCESS_KEY_CIPHER_SECRET", "cipher-secret")
2425
t.Setenv("MANAGER_AUTH_JWT_SECRET", "test-secret")
2526
t.Setenv("MANAGER_AUTH_ACCESS_TOKEN_TTL_SECONDS", "120")
2627

@@ -41,6 +42,9 @@ func TestLoadManager_MergesDefaultsAndEnv(t *testing.T) {
4142
if cfg.Database.MaxOpenConns != 25 {
4243
t.Fatalf("expected MANAGER_DB_MAX_OPEN_CONNS override, got %d", cfg.Database.MaxOpenConns)
4344
}
45+
if cfg.Security.AccessKeyCipherSecret != "cipher-secret" {
46+
t.Fatalf("expected MANAGER_ACCESS_KEY_CIPHER_SECRET override, got %q", cfg.Security.AccessKeyCipherSecret)
47+
}
4448
if cfg.Server.HealthPath != "/health" {
4549
t.Fatalf("expected health path from file, got %q", cfg.Server.HealthPath)
4650
}
@@ -79,6 +83,9 @@ func TestLoadManager_DefaultFallback(t *testing.T) {
7983
if cfg.Database.DSN == "" {
8084
t.Fatalf("expected default database dsn")
8185
}
86+
if cfg.Security.AccessKeyCipherSecret == "" {
87+
t.Fatalf("expected default access key cipher secret")
88+
}
8289
if cfg.Auth.JWTSecret == "" {
8390
t.Fatalf("expected default auth jwt secret")
8491
}
@@ -91,3 +98,11 @@ func TestManagerConfigValidate_AuthSecretRequired(t *testing.T) {
9198
t.Fatalf("expected missing auth.jwt_secret error")
9299
}
93100
}
101+
102+
func TestManagerConfigValidate_AccessKeyCipherSecretRequired(t *testing.T) {
103+
cfg := DefaultManagerConfig()
104+
cfg.Security.AccessKeyCipherSecret = ""
105+
if err := cfg.Validate(); err == nil {
106+
t.Fatalf("expected missing security.access_key_cipher_secret error")
107+
}
108+
}

internal/manager/auth/service.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,35 @@ func (s *Service) Authenticate(ctx context.Context, accessToken string) (Princip
188188
}, nil
189189
}
190190

191+
func (s *Service) Reauthenticate(ctx context.Context, userID uuid.UUID, password string) error {
192+
if err := s.validateReady(); err != nil {
193+
return err
194+
}
195+
if userID == uuid.Nil || strings.TrimSpace(password) == "" {
196+
return fmt.Errorf("%w: user_id and password are required", ErrInvalidArgument)
197+
}
198+
199+
user, err := s.users.GetByID(ctx, userID)
200+
if err != nil {
201+
if errors.Is(err, ErrUserNotFound) {
202+
return ErrUnauthorized
203+
}
204+
return fmt.Errorf("load user by id: %w", err)
205+
}
206+
207+
if !isSupportedRole(user.Role) || !isSupportedStatus(user.Status) {
208+
return ErrUnauthorized
209+
}
210+
if user.Status != contracts.UserStatusActive {
211+
return ErrForbidden
212+
}
213+
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
214+
return ErrInvalidCredentials
215+
}
216+
217+
return nil
218+
}
219+
191220
func HashPassword(password string) (string, error) {
192221
if strings.TrimSpace(password) == "" {
193222
return "", fmt.Errorf("%w: password is required", ErrInvalidArgument)

internal/manager/auth/service_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,3 +342,43 @@ func TestService_RegisterConflict(t *testing.T) {
342342
t.Fatalf("expected ErrConflict, got %v", err)
343343
}
344344
}
345+
346+
func TestService_Reauthenticate(t *testing.T) {
347+
passwordHash, err := HashPassword("P@ssw0rd")
348+
if err != nil {
349+
t.Fatalf("HashPassword() error = %v", err)
350+
}
351+
352+
userID := uuid.New()
353+
user := User{
354+
ID: userID,
355+
Email: "reauth@example.com",
356+
PasswordHash: passwordHash,
357+
Role: contracts.RoleAdmin,
358+
Status: contracts.UserStatusActive,
359+
}
360+
361+
repo := &fakeUserRepository{
362+
byID: map[uuid.UUID]User{userID: user},
363+
byEmail: map[string]User{"reauth@example.com": user},
364+
}
365+
tokens, err := NewJWTManager(JWTManagerConfig{
366+
Secret: "unit-test-secret",
367+
Issuer: "unit-test",
368+
AccessTTL: 5 * time.Minute,
369+
RefreshTTL: 30 * time.Minute,
370+
})
371+
if err != nil {
372+
t.Fatalf("NewJWTManager() error = %v", err)
373+
}
374+
375+
service := NewService(repo, tokens)
376+
377+
if err := service.Reauthenticate(context.Background(), userID, "P@ssw0rd"); err != nil {
378+
t.Fatalf("Reauthenticate() error = %v", err)
379+
}
380+
381+
if err := service.Reauthenticate(context.Background(), userID, "bad-password"); !errors.Is(err, ErrInvalidCredentials) {
382+
t.Fatalf("expected ErrInvalidCredentials, got %v", err)
383+
}
384+
}

0 commit comments

Comments
 (0)