@@ -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
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 ---- 域通知收件人与密码策略 ----
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user