用户 API Key 管理:增删查、启用签名凭据;升级 SDK
CI / test (push) Successful in 48s

This commit is contained in:
2026-07-22 19:38:14 +08:00
parent 8894330eba
commit a95a8255bf
13 changed files with 1032 additions and 4 deletions
+1 -1
View File
@@ -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
+193
View File
@@ -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": {
+193
View File
@@ -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": {
+123
View File
@@ -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:
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+4
View File
@@ -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)
+5
View File
@@ -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"`
+95
View File
@@ -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 ---- 域通知收件人与密码策略 ----
+4
View File
@@ -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)
+60
View File
@@ -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)
+157
View File
@@ -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)
}
+194
View File
@@ -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)
}
})
}
}