From a95a8255bf760cbd137e68d9cf816b40829522b4 Mon Sep 17 00:00:00 2001 From: Wang Defa <1+wangdefa@noreply.gitea.bcde.io> Date: Wed, 22 Jul 2026 19:38:14 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=A8=E6=88=B7=20API=20Key=20=E7=AE=A1?= =?UTF-8?q?=E7=90=86:=E5=A2=9E=E5=88=A0=E6=9F=A5=E3=80=81=E5=90=AF?= =?UTF-8?q?=E7=94=A8=E7=AD=BE=E5=90=8D=E5=87=AD=E6=8D=AE;=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/docs.go | 193 +++++++++++++++++++++++++++ docs/swagger.json | 193 +++++++++++++++++++++++++++ docs/swagger.yaml | 123 ++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- internal/api/routes_oci.go | 4 + internal/api/swagger_resp.go | 5 + internal/api/tenant.go | 95 ++++++++++++++ internal/oci/client.go | 4 + internal/oci/tenantuser.go | 60 +++++++++ internal/service/userapikey.go | 157 ++++++++++++++++++++++ internal/service/userapikey_test.go | 194 ++++++++++++++++++++++++++++ 13 files changed, 1032 insertions(+), 4 deletions(-) create mode 100644 internal/service/userapikey.go create mode 100644 internal/service/userapikey_test.go diff --git a/README.md b/README.md index 5626a57..838f490 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependenc ## 致谢 -感谢 [Yohann0617/oci-helper](https://github.com/Yohann0617/oci-helper) 项目为本项目提供思路。 +感谢 [Yohann0617/oci-helper](https://github.com/Yohann0617/oci-helper) 为本项目提供思路。 ## License diff --git a/docs/docs.go b/docs/docs.go index 1300b32..fcbd728 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1572,6 +1572,43 @@ const docTemplate = `{ } } }, + "/api/v1/oci-configs/{id}/activate-api-key": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "将刚创建的 key 设为本配置签名凭据:验证可用后落库,不删除旧 key;私钥为创建时下发的那份回传。", + "tags": [ + "租户 IAM" + ], + "summary": "启用 API Key 为面板签名凭据", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "请求体:fingerprint 与 privateKey", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "204": { + "description": "无内容" + } + } + } + }, "/api/v1/oci-configs/{id}/audit-events": { "get": { "security": [ @@ -5795,6 +5832,77 @@ const docTemplate = `{ } }, "/api/v1/oci-configs/{id}/users/{userId}/api-keys": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "租户 IAM" + ], + "summary": "列出用户 API Key", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "userId", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.userApiKeysResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "后端生成 RSA-2048 密钥对并上传公钥;私钥仅本次响应返回,不落库。", + "tags": [ + "租户 IAM" + ], + "summary": "为用户新增 API Key", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "userId", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/oci-portal_internal_service.CreatedApiKey" + } + } + } + }, "delete": { "security": [ { @@ -5831,6 +5939,48 @@ const docTemplate = `{ } } }, + "/api/v1/oci-configs/{id}/users/{userId}/api-keys/{fingerprint}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "当前配置正在使用的 key 拒删(409),避免面板失联。", + "tags": [ + "租户 IAM" + ], + "summary": "删除用户单把 API Key", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "userId", + "name": "userId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "key 指纹", + "name": "fingerprint", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "无内容" + } + } + } + }, "/api/v1/oci-configs/{id}/users/{userId}/mfa-devices": { "delete": { "security": [ @@ -8612,6 +8762,17 @@ const docTemplate = `{ } } }, + "internal_api.userApiKeysResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_service.UserApiKeyInfo" + } + } + } + }, "internal_api.webConsoleSessionResponse": { "type": "object", "properties": { @@ -11330,6 +11491,20 @@ const docTemplate = `{ } } }, + "oci-portal_internal_service.CreatedApiKey": { + "type": "object", + "properties": { + "configIni": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "privateKey": { + "type": "string" + } + } + }, "oci-portal_internal_service.ImportFail": { "type": "object", "properties": { @@ -11912,6 +12087,24 @@ const docTemplate = `{ "type": "string" } } + }, + "oci-portal_internal_service.UserApiKeyInfo": { + "type": "object", + "properties": { + "configIni": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "isCurrent": { + "description": "IsCurrent 表示该 key 正被当前配置用于签名请求", + "type": "boolean" + }, + "timeCreated": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.json b/docs/swagger.json index 49a8923..0ad8fa8 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1565,6 +1565,43 @@ } } }, + "/api/v1/oci-configs/{id}/activate-api-key": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "将刚创建的 key 设为本配置签名凭据:验证可用后落库,不删除旧 key;私钥为创建时下发的那份回传。", + "tags": [ + "租户 IAM" + ], + "summary": "启用 API Key 为面板签名凭据", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "请求体:fingerprint 与 privateKey", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "204": { + "description": "无内容" + } + } + } + }, "/api/v1/oci-configs/{id}/audit-events": { "get": { "security": [ @@ -5788,6 +5825,77 @@ } }, "/api/v1/oci-configs/{id}/users/{userId}/api-keys": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "租户 IAM" + ], + "summary": "列出用户 API Key", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "userId", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.userApiKeysResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "后端生成 RSA-2048 密钥对并上传公钥;私钥仅本次响应返回,不落库。", + "tags": [ + "租户 IAM" + ], + "summary": "为用户新增 API Key", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "userId", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/oci-portal_internal_service.CreatedApiKey" + } + } + } + }, "delete": { "security": [ { @@ -5824,6 +5932,48 @@ } } }, + "/api/v1/oci-configs/{id}/users/{userId}/api-keys/{fingerprint}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "当前配置正在使用的 key 拒删(409),避免面板失联。", + "tags": [ + "租户 IAM" + ], + "summary": "删除用户单把 API Key", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "userId", + "name": "userId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "key 指纹", + "name": "fingerprint", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "无内容" + } + } + } + }, "/api/v1/oci-configs/{id}/users/{userId}/mfa-devices": { "delete": { "security": [ @@ -8605,6 +8755,17 @@ } } }, + "internal_api.userApiKeysResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_service.UserApiKeyInfo" + } + } + } + }, "internal_api.webConsoleSessionResponse": { "type": "object", "properties": { @@ -11323,6 +11484,20 @@ } } }, + "oci-portal_internal_service.CreatedApiKey": { + "type": "object", + "properties": { + "configIni": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "privateKey": { + "type": "string" + } + } + }, "oci-portal_internal_service.ImportFail": { "type": "object", "properties": { @@ -11905,6 +12080,24 @@ "type": "string" } } + }, + "oci-portal_internal_service.UserApiKeyInfo": { + "type": "object", + "properties": { + "configIni": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "isCurrent": { + "description": "IsCurrent 表示该 key 正被当前配置用于签名请求", + "type": "boolean" + }, + "timeCreated": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 1b55ee2..3341076 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -820,6 +820,13 @@ definitions: usedBy: type: integer type: object + internal_api.userApiKeysResponse: + properties: + items: + items: + $ref: '#/definitions/oci-portal_internal_service.UserApiKeyInfo' + type: array + type: object internal_api.webConsoleSessionResponse: properties: sessionId: @@ -2619,6 +2626,15 @@ definitions: tenancyOcid: type: string type: object + oci-portal_internal_service.CreatedApiKey: + properties: + configIni: + type: string + fingerprint: + type: string + privateKey: + type: string + type: object oci-portal_internal_service.ImportFail: properties: line: @@ -3006,6 +3022,18 @@ definitions: oidcIssuer: type: string type: object + oci-portal_internal_service.UserApiKeyInfo: + properties: + configIni: + type: string + fingerprint: + type: string + isCurrent: + description: IsCurrent 表示该 key 正被当前配置用于签名请求 + type: boolean + timeCreated: + type: string + type: object info: contact: {} description: '自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization: @@ -3964,6 +3992,29 @@ paths: summary: 更新租户配置 tags: - 租户配置 + /api/v1/oci-configs/{id}/activate-api-key: + post: + description: 将刚创建的 key 设为本配置签名凭据:验证可用后落库,不删除旧 key;私钥为创建时下发的那份回传。 + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 请求体:fingerprint 与 privateKey + in: body + name: body + required: true + schema: + type: object + responses: + "204": + description: 无内容 + security: + - BearerAuth: [] + summary: 启用 API Key 为面板签名凭据 + tags: + - 租户 IAM /api/v1/oci-configs/{id}/audit-events: get: parameters: @@ -6631,6 +6682,78 @@ paths: summary: 清除用户全部 API Key tags: - 租户 IAM + get: + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: userId + in: path + name: userId + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api.userApiKeysResponse' + security: + - BearerAuth: [] + summary: 列出用户 API Key + tags: + - 租户 IAM + post: + description: 后端生成 RSA-2048 密钥对并上传公钥;私钥仅本次响应返回,不落库。 + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: userId + in: path + name: userId + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/oci-portal_internal_service.CreatedApiKey' + security: + - BearerAuth: [] + summary: 为用户新增 API Key + tags: + - 租户 IAM + /api/v1/oci-configs/{id}/users/{userId}/api-keys/{fingerprint}: + delete: + description: 当前配置正在使用的 key 拒删(409),避免面板失联。 + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: userId + in: path + name: userId + required: true + type: string + - description: key 指纹 + in: path + name: fingerprint + required: true + type: string + responses: + "204": + description: 无内容 + security: + - BearerAuth: [] + summary: 删除用户单把 API Key + tags: + - 租户 IAM /api/v1/oci-configs/{id}/users/{userId}/mfa-devices: delete: parameters: diff --git a/go.mod b/go.mod index 93e4335..ec0455c 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/glebarez/sqlite v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/websocket v1.5.3 - github.com/oracle/oci-go-sdk/v65 v65.121.0 + github.com/oracle/oci-go-sdk/v65 v65.121.1 github.com/pquerna/otp v1.5.0 github.com/robfig/cron/v3 v3.0.1 github.com/swaggo/files v1.0.1 diff --git a/go.sum b/go.sum index 1b19e52..d009b64 100644 --- a/go.sum +++ b/go.sum @@ -119,8 +119,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/oracle/oci-go-sdk/v65 v65.121.0 h1:1J+5ARgrodrx8kzFy/hxznaoUzz43jr0EestCzEaOHw= -github.com/oracle/oci-go-sdk/v65 v65.121.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ= +github.com/oracle/oci-go-sdk/v65 v65.121.1 h1:nXrE3r1qmBULT6Zp2koM2bJGDvsFtMX/be/NV9MS3Bw= +github.com/oracle/oci-go-sdk/v65 v65.121.1/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/api/routes_oci.go b/internal/api/routes_oci.go index b3fc587..9a446b0 100644 --- a/internal/api/routes_oci.go +++ b/internal/api/routes_oci.go @@ -160,6 +160,10 @@ func registerOciTenantIAM(g *gin.RouterGroup, h *ociConfigHandler) { g.POST("/oci-configs/:id/users/:userId/reset-password", h.resetTenantUserPassword) g.DELETE("/oci-configs/:id/users/:userId/mfa-devices", h.deleteTenantUserMfa) g.DELETE("/oci-configs/:id/users/:userId/api-keys", h.deleteTenantUserApiKeys) + g.GET("/oci-configs/:id/users/:userId/api-keys", h.listTenantUserApiKeys) + g.POST("/oci-configs/:id/users/:userId/api-keys", h.addTenantUserApiKey) + g.DELETE("/oci-configs/:id/users/:userId/api-keys/:fingerprint", h.deleteTenantUserApiKey) + g.POST("/oci-configs/:id/activate-api-key", h.activateApiKey) g.GET("/oci-configs/:id/notification-recipients", h.getNotificationRecipients) g.PUT("/oci-configs/:id/notification-recipients", h.updateNotificationRecipients) g.GET("/oci-configs/:id/password-policies", h.listPasswordPolicies) diff --git a/internal/api/swagger_resp.go b/internal/api/swagger_resp.go index 3730161..1daab89 100644 --- a/internal/api/swagger_resp.go +++ b/internal/api/swagger_resp.go @@ -157,6 +157,11 @@ type deletedApiKeysResponse struct { DeletedApiKeys int `json:"deletedApiKeys"` } +// userApiKeysResponse 是用户 API 签名 key 列表。 +type userApiKeysResponse struct { + Items []service.UserApiKeyInfo `json:"items"` +} + // createInstancesResponse 是批量创建实例结果;部分成功仍 201,errors 为逐台失败信息。 type createInstancesResponse struct { Instances []oci.Instance `json:"instances"` diff --git a/internal/api/tenant.go b/internal/api/tenant.go index 2c47475..a60230a 100644 --- a/internal/api/tenant.go +++ b/internal/api/tenant.go @@ -371,6 +371,101 @@ func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"deletedApiKeys": deleted}) } +// @Summary 列出用户 API Key +// @Tags 租户 IAM +// @Param id path int true "配置 ID" +// @Param userId path string true "userId" +// @Success 200 {object} userApiKeysResponse +// @Security BearerAuth +// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys [get] +func (h *ociConfigHandler) listTenantUserApiKeys(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + keys, err := h.svc.TenantUserApiKeys(c.Request.Context(), id, c.Param("userId")) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": keys}) +} + +// @Summary 为用户新增 API Key +// @Description 后端生成 RSA-2048 密钥对并上传公钥;私钥仅本次响应返回,不落库。 +// @Tags 租户 IAM +// @Param id path int true "配置 ID" +// @Param userId path string true "userId" +// @Success 200 {object} service.CreatedApiKey +// @Security BearerAuth +// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys [post] +func (h *ociConfigHandler) addTenantUserApiKey(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + created, err := h.svc.AddTenantUserApiKey(c.Request.Context(), id, c.Param("userId")) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, created) +} + +// @Summary 删除用户单把 API Key +// @Description 当前配置正在使用的 key 拒删(409),避免面板失联。 +// @Tags 租户 IAM +// @Param id path int true "配置 ID" +// @Param userId path string true "userId" +// @Param fingerprint path string true "key 指纹" +// @Success 204 "无内容" +// @Security BearerAuth +// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys/{fingerprint} [delete] +func (h *ociConfigHandler) deleteTenantUserApiKey(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + err := h.svc.DeleteTenantUserApiKey(c.Request.Context(), id, c.Param("userId"), c.Param("fingerprint")) + if errors.Is(err, service.ErrCurrentApiKey) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + if err != nil { + respondError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +// @Summary 启用 API Key 为面板签名凭据 +// @Description 将刚创建的 key 设为本配置签名凭据:验证可用后落库,不删除旧 key;私钥为创建时下发的那份回传。 +// @Tags 租户 IAM +// @Param id path int true "配置 ID" +// @Param body body object true "请求体:fingerprint 与 privateKey" +// @Success 204 "无内容" +// @Security BearerAuth +// @Router /api/v1/oci-configs/{id}/activate-api-key [post] +func (h *ociConfigHandler) activateApiKey(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + var req struct { + Fingerprint string `json:"fingerprint"` + PrivateKey string `json:"privateKey"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.Fingerprint == "" || req.PrivateKey == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "fingerprint 与 privateKey 必填"}) + return + } + if err := h.svc.ActivateApiKey(c.Request.Context(), id, req.Fingerprint, req.PrivateKey); err != nil { + respondError(c, err) + return + } + c.Status(http.StatusNoContent) +} + // ---- 域通知收件人与密码策略 ---- // @Summary ---- 域通知收件人与密码策略 ---- diff --git a/internal/oci/client.go b/internal/oci/client.go index 5caf4d1..cc956f0 100644 --- a/internal/oci/client.go +++ b/internal/oci/client.go @@ -189,6 +189,10 @@ type Client interface { ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error) DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (int, error) DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error) + // 用户 API 签名 key 管理:列出(标注当前使用)、上传公钥(返回指纹)、按指纹删单把。 + ListTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string) ([]TenantUserApiKey, error) + UploadTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, publicKeyPEM string) (string, error) + DeleteTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, fingerprint string) error // 域设置:通知收件人、密码策略、身份设置。 GetNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string) (NotificationRecipients, error) UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []string) (NotificationRecipients, error) diff --git a/internal/oci/tenantuser.go b/internal/oci/tenantuser.go index 8f55896..3939359 100644 --- a/internal/oci/tenantuser.go +++ b/internal/oci/tenantuser.go @@ -760,6 +760,66 @@ func (c *RealClient) DeleteTenantUserApiKeys(ctx context.Context, cred Credentia return deleted, nil } +// TenantUserApiKey 是用户名下的一把 API 签名 key。 +type TenantUserApiKey struct { + Fingerprint string `json:"fingerprint"` + TimeCreated *time.Time `json:"timeCreated"` + // IsCurrent 表示该 key 正被当前配置用于签名请求 + IsCurrent bool `json:"isCurrent"` +} + +// ListTenantUserApiKeys 实现 Client:列出用户的 API 签名 key。 +func (c *RealClient) ListTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string) ([]TenantUserApiKey, error) { + ic, err := c.identityClientAt(cred, homeRegion) + if err != nil { + return nil, err + } + resp, err := ic.ListApiKeys(ctx, identity.ListApiKeysRequest{UserId: &userID}) + if err != nil { + return nil, fmt.Errorf("list api keys: %w", err) + } + keys := make([]TenantUserApiKey, 0, len(resp.Items)) + for _, item := range resp.Items { + k := TenantUserApiKey{Fingerprint: deref(item.Fingerprint)} + if item.TimeCreated != nil { + t := item.TimeCreated.Time + k.TimeCreated = &t + } + k.IsCurrent = userID == cred.UserOCID && k.Fingerprint == cred.Fingerprint + keys = append(keys, k) + } + return keys, nil +} + +// UploadTenantUserApiKey 实现 Client:为用户上传 RSA 公钥,返回 OCI 回填的指纹。 +func (c *RealClient) UploadTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, publicKeyPEM string) (string, error) { + ic, err := c.identityClientAt(cred, homeRegion) + if err != nil { + return "", err + } + resp, err := ic.UploadApiKey(ctx, identity.UploadApiKeyRequest{ + UserId: &userID, + CreateApiKeyDetails: identity.CreateApiKeyDetails{Key: &publicKeyPEM}, + }) + if err != nil { + return "", fmt.Errorf("upload api key: %w", err) + } + return deref(resp.Fingerprint), nil +} + +// DeleteTenantUserApiKey 实现 Client:按指纹删除用户的单把 API key。 +func (c *RealClient) DeleteTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, fingerprint string) error { + ic, err := c.identityClientAt(cred, homeRegion) + if err != nil { + return err + } + _, err = ic.DeleteApiKey(ctx, identity.DeleteApiKeyRequest{UserId: &userID, Fingerprint: &fingerprint}) + if err != nil { + return fmt.Errorf("delete api key %s: %w", fingerprint, err) + } + return nil +} + // scimUserID 用经典 OCID 在 Identity Domain 中解析 SCIM 用户 ID。 func scimUserID(ctx context.Context, dc identitydomains.IdentityDomainsClient, classicOCID string) (string, error) { u, found, err := domainUserByOcid(ctx, dc, classicOCID) diff --git a/internal/service/userapikey.go b/internal/service/userapikey.go new file mode 100644 index 0000000..6b41a98 --- /dev/null +++ b/internal/service/userapikey.go @@ -0,0 +1,157 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "time" + + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +// ErrCurrentApiKey 拒绝删除当前配置正在使用的签名 key。 +var ErrCurrentApiKey = errors.New("当前配置正在使用的 API Key 不可删除,请先替换") + +// apiKeyVerifyAttempts / apiKeyVerifyDelay 控制新 key 生效验证的重试节奏; +// OCI 上传公钥后生效有秒级延迟。测试将 delay 调小。 +var ( + apiKeyVerifyAttempts = 6 + apiKeyVerifyDelay = 2 * time.Second +) + +// CreatedApiKey 是新建/轮换 API key 的一次性返回;私钥仅此一次,不落库。 +type CreatedApiKey struct { + Fingerprint string `json:"fingerprint"` + PrivateKey string `json:"privateKey"` + ConfigIni string `json:"configIni"` +} + +// UserApiKeyInfo 是列表项:key 元数据加该 key 的 CLI config 模板(预览用)。 +type UserApiKeyInfo struct { + oci.TenantUserApiKey + ConfigIni string `json:"configIni"` +} + +// TenantUserApiKeys 列出用户 API 签名 key(oci 层已标注当前使用)。 +func (s *OciConfigService) TenantUserApiKeys(ctx context.Context, id uint, userID string) ([]UserApiKeyInfo, error) { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return nil, err + } + keys, err := s.client.ListTenantUserApiKeys(ctx, cred, homeRegion, userID) + if err != nil { + return nil, err + } + out := make([]UserApiKeyInfo, 0, len(keys)) + for _, k := range keys { + out = append(out, UserApiKeyInfo{TenantUserApiKey: k, ConfigIni: ociConfigIni(cred, userID, k.Fingerprint)}) + } + return out, nil +} + +// AddTenantUserApiKey 生成 RSA-2048 密钥对并上传公钥;私钥仅本次返回。 +func (s *OciConfigService) AddTenantUserApiKey(ctx context.Context, id uint, userID string) (CreatedApiKey, error) { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return CreatedApiKey{}, err + } + privPEM, pubPEM, err := generateRsaKeyPair() + if err != nil { + return CreatedApiKey{}, err + } + fp, err := s.client.UploadTenantUserApiKey(ctx, cred, homeRegion, userID, pubPEM) + if err != nil { + return CreatedApiKey{}, err + } + return CreatedApiKey{Fingerprint: fp, PrivateKey: privPEM, ConfigIni: ociConfigIni(cred, userID, fp)}, nil +} + +// DeleteTenantUserApiKey 删除用户单把 API key;当前签名 key 拒删以免面板失联。 +func (s *OciConfigService) DeleteTenantUserApiKey(ctx context.Context, id uint, userID, fingerprint string) error { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return err + } + if userID == cred.UserOCID && fingerprint == cred.Fingerprint { + return ErrCurrentApiKey + } + return s.client.DeleteTenantUserApiKey(ctx, cred, homeRegion, userID, fingerprint) +} + +// ActivateApiKey 把刚创建的 key 设为本配置的签名凭据:验证可用后落库,不删除旧 key。 +// 私钥由前端回传(仅创建时下发过);归属无需显式校验——非本签名用户的 key 验证必失败。 +func (s *OciConfigService) ActivateApiKey(ctx context.Context, id uint, fingerprint, privateKey string) error { + cfg, err := s.Get(ctx, id) + if err != nil { + return err + } + cred, err := s.credentialsOf(cfg) + if err != nil { + return err + } + newCred := cred + newCred.Fingerprint, newCred.PrivateKey, newCred.Passphrase = fingerprint, privateKey, "" + if err := newCred.Validate(); err != nil { + return err + } + if err := s.waitApiKeyUsable(ctx, newCred); err != nil { + return err + } + return s.persistSigningKey(cfg, newCred) +} + +// waitApiKeyUsable 用新凭据测活,等待上传的公钥在 OCI 侧生效。 +func (s *OciConfigService) waitApiKeyUsable(ctx context.Context, cred oci.Credentials) error { + var err error + for i := 0; i < apiKeyVerifyAttempts; i++ { + if _, err = s.client.ValidateKey(ctx, cred); err == nil { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(apiKeyVerifyDelay): + } + } + return fmt.Errorf("new api key not usable: %w", err) +} + +// persistSigningKey 加密新私钥,更新配置指纹并清空口令密文(面板生成的 key 无口令)。 +func (s *OciConfigService) persistSigningKey(cfg *model.OciConfig, newCred oci.Credentials) error { + enc, err := s.cipher.EncryptString(newCred.PrivateKey) + if err != nil { + return fmt.Errorf("encrypt private key: %w", err) + } + updates := map[string]any{"fingerprint": newCred.Fingerprint, "private_key_enc": enc, "passphrase_enc": ""} + if err := s.db.Model(cfg).Updates(updates).Error; err != nil { + return fmt.Errorf("persist rotated key: %w", err) + } + return nil +} + +// generateRsaKeyPair 生成 RSA-2048 密钥对,返回 PKCS#1 私钥 PEM 与 PKIX 公钥 PEM。 +func generateRsaKeyPair() (privatePEM, publicPEM string, err error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return "", "", fmt.Errorf("generate rsa key: %w", err) + } + privBlock := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)} + pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + return "", "", fmt.Errorf("marshal public key: %w", err) + } + pubBlock := &pem.Block{Type: "PUBLIC KEY", Bytes: pubDER} + return string(pem.EncodeToMemory(privBlock)), string(pem.EncodeToMemory(pubBlock)), nil +} + +// ociConfigIni 拼装 OCI CLI config 文本;key_file 需用户保存私钥后自行填写。 +func ociConfigIni(cred oci.Credentials, userID, fingerprint string) string { + return fmt.Sprintf( + "[DEFAULT]\nuser=%s\nfingerprint=%s\ntenancy=%s\nregion=%s\n# 保存私钥到本机后替换为实际路径\nkey_file=~/.oci/oci_api_key.pem\n", + userID, fingerprint, cred.TenancyOCID, cred.Region) +} diff --git a/internal/service/userapikey_test.go b/internal/service/userapikey_test.go new file mode 100644 index 0000000..054e46b --- /dev/null +++ b/internal/service/userapikey_test.go @@ -0,0 +1,194 @@ +package service + +import ( + "context" + "crypto/x509" + "encoding/pem" + "errors" + "strings" + "testing" + "time" + + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +// apiKeyClient 记录 API key 相关调用的 fake。 +type apiKeyClient struct { + oci.Client + + keys []oci.TenantUserApiKey + uploadFp string + uploadErr error + uploadedPub string + deleted []string + validateErr error + validated []string // ValidateKey 收到的指纹序列 +} + +func (f *apiKeyClient) ListTenantUserApiKeys(ctx context.Context, cred oci.Credentials, homeRegion, userID string) ([]oci.TenantUserApiKey, error) { + return f.keys, nil +} + +func (f *apiKeyClient) UploadTenantUserApiKey(ctx context.Context, cred oci.Credentials, homeRegion, userID, publicKeyPEM string) (string, error) { + if f.uploadErr != nil { + return "", f.uploadErr + } + f.uploadedPub = publicKeyPEM + return f.uploadFp, nil +} + +func (f *apiKeyClient) DeleteTenantUserApiKey(ctx context.Context, cred oci.Credentials, homeRegion, userID, fingerprint string) error { + f.deleted = append(f.deleted, fingerprint) + return nil +} + +func (f *apiKeyClient) ValidateKey(ctx context.Context, cred oci.Credentials) (oci.TenancyInfo, error) { + f.validated = append(f.validated, cred.Fingerprint) + if f.validateErr != nil { + return oci.TenancyInfo{}, f.validateErr + } + return oci.TenancyInfo{Name: "t"}, nil +} + +const testKeyPEM = "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----" + +// seedApiKeyConfig 落一条可解密的配置,当前签名 key 指纹为 aa:bb。 +func seedApiKeyConfig(t *testing.T, s *OciConfigService) *model.OciConfig { + t.Helper() + enc, err := s.cipher.EncryptString(testKeyPEM) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + cfg := &model.OciConfig{ + Alias: "t1", UserOCID: "ocid1.user.oc1..me", TenancyOCID: "ocid1.tenancy.oc1..t", + Fingerprint: "aa:bb", Region: "ap-tokyo-1", PrivateKeyEnc: enc, + } + if err := s.db.Create(cfg).Error; err != nil { + t.Fatalf("seed config: %v", err) + } + return cfg +} + +func TestDeleteTenantUserApiKey(t *testing.T) { + tests := []struct { + name string + userID string + fingerprint string + wantErr error + wantDeleted bool + }{ + {"当前签名 key 拒删", "ocid1.user.oc1..me", "aa:bb", ErrCurrentApiKey, false}, + {"当前用户其他指纹可删", "ocid1.user.oc1..me", "cc:dd", nil, true}, + {"其他用户同指纹可删", "ocid1.user.oc1..other", "aa:bb", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fc := &apiKeyClient{} + s := newTestService(t, fc) + cfg := seedApiKeyConfig(t, s) + err := s.DeleteTenantUserApiKey(context.Background(), cfg.ID, tt.userID, tt.fingerprint) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("err = %v, want %v", err, tt.wantErr) + } + if got := len(fc.deleted) > 0; got != tt.wantDeleted { + t.Fatalf("deleted = %v, want deleted=%v", fc.deleted, tt.wantDeleted) + } + }) + } +} + +func TestTenantUserApiKeys(t *testing.T) { + fc := &apiKeyClient{keys: []oci.TenantUserApiKey{{Fingerprint: "aa:bb", IsCurrent: true}}} + s := newTestService(t, fc) + cfg := seedApiKeyConfig(t, s) + + items, err := s.TenantUserApiKeys(context.Background(), cfg.ID, "ocid1.user.oc1..me") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(items) != 1 || !items[0].IsCurrent { + t.Fatalf("items = %+v", items) + } + if !strings.Contains(items[0].ConfigIni, "fingerprint=aa:bb") { + t.Fatalf("configIni missing fingerprint:\n%s", items[0].ConfigIni) + } +} + +func TestAddTenantUserApiKey(t *testing.T) { + fc := &apiKeyClient{uploadFp: "11:22"} + s := newTestService(t, fc) + cfg := seedApiKeyConfig(t, s) + + created, err := s.AddTenantUserApiKey(context.Background(), cfg.ID, "ocid1.user.oc1..other") + if err != nil { + t.Fatalf("add: %v", err) + } + if created.Fingerprint != "11:22" { + t.Fatalf("fingerprint = %q", created.Fingerprint) + } + block, _ := pem.Decode([]byte(created.PrivateKey)) + if block == nil || block.Type != "RSA PRIVATE KEY" { + t.Fatalf("private key not PKCS#1 PEM") + } + if _, err := x509.ParsePKCS1PrivateKey(block.Bytes); err != nil { + t.Fatalf("parse private key: %v", err) + } + if !strings.Contains(fc.uploadedPub, "PUBLIC KEY") { + t.Fatalf("uploaded public key = %q", fc.uploadedPub) + } + for _, want := range []string{"user=ocid1.user.oc1..other", "fingerprint=11:22", "tenancy=ocid1.tenancy.oc1..t", "region=ap-tokyo-1"} { + if !strings.Contains(created.ConfigIni, want) { + t.Fatalf("configIni missing %q:\n%s", want, created.ConfigIni) + } + } +} + +func TestActivateApiKey(t *testing.T) { + apiKeyVerifyDelay = time.Millisecond + const newKey = "-----BEGIN RSA PRIVATE KEY-----\nnew\n-----END RSA PRIVATE KEY-----" + tests := []struct { + name string + privateKey string + validateErr error + wantErr bool + wantFp string // 期望落库指纹 + }{ + {"成功:验证通过后落库,不删任何 key", newKey, nil, false, "11:22"}, + {"验证失败:配置不动", newKey, errors.New("401"), true, "aa:bb"}, + {"私钥非 PEM:直接拒绝", "not-a-pem", nil, true, "aa:bb"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fc := &apiKeyClient{validateErr: tt.validateErr} + s := newTestService(t, fc) + cfg := seedApiKeyConfig(t, s) + + err := s.ActivateApiKey(context.Background(), cfg.ID, "11:22", tt.privateKey) + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tt.wantErr) + } + var got model.OciConfig + if err := s.db.First(&got, cfg.ID).Error; err != nil { + t.Fatalf("reload: %v", err) + } + if got.Fingerprint != tt.wantFp { + t.Fatalf("fingerprint = %q, want %q", got.Fingerprint, tt.wantFp) + } + if len(fc.deleted) != 0 { + t.Fatalf("deleted = %v, want none", fc.deleted) + } + if tt.wantErr { + return + } + // 成功路径:落库私钥可解密且与回传一致,验证调用用的是新指纹 + plain, err := s.cipher.DecryptString(got.PrivateKeyEnc) + if err != nil || plain != newKey { + t.Fatalf("persisted key mismatch (err=%v)", err) + } + if len(fc.validated) == 0 || fc.validated[0] != "11:22" { + t.Fatalf("validated = %v", fc.validated) + } + }) + } +}