Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1914880ac | ||
|
|
a95a8255bf |
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
||||||
|
|
||||||
|
## [0.8.2]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 租户 IAM 用户新增 API Key 管理:支持列出密钥并标注当前签名凭据、提供 OCI CLI 配置模板、由服务端生成 RSA-2048 密钥对并上传公钥(私钥仅在创建响应中返回),以及按指纹删除单把密钥;新密钥可在验证 OCI 可用后启用为面板签名凭据并加密保存,旧密钥保留,当前在用密钥禁止直接删除
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 升级 OCI Go SDK 至 v65.121.1
|
||||||
|
|
||||||
## [0.8.1]
|
## [0.8.1]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
v0.8.1
|
v0.8.2
|
||||||
|
|||||||
@@ -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
|
## License
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// @title OCI Portal API
|
// @title OCI Portal API
|
||||||
// @version 0.8.1
|
// @version 0.8.2
|
||||||
// @description 自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization: Bearer 或 x-api-key)。
|
// @description 自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization: Bearer 或 x-api-key)。
|
||||||
// @BasePath /
|
// @BasePath /
|
||||||
// @securityDefinitions.apikey BearerAuth
|
// @securityDefinitions.apikey BearerAuth
|
||||||
|
|||||||
+194
-1
@@ -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": {
|
"/api/v1/oci-configs/{id}/audit-events": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -5795,6 +5832,77 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/oci-configs/{id}/users/{userId}/api-keys": {
|
"/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": {
|
"delete": {
|
||||||
"security": [
|
"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": {
|
"/api/v1/oci-configs/{id}/users/{userId}/mfa-devices": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"security": [
|
"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": {
|
"internal_api.webConsoleSessionResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
"oci-portal_internal_service.ImportFail": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -11912,6 +12087,24 @@ const docTemplate = `{
|
|||||||
"type": "string"
|
"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": {
|
"securityDefinitions": {
|
||||||
@@ -11926,7 +12119,7 @@ const docTemplate = `{
|
|||||||
|
|
||||||
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||||
var SwaggerInfo = &swag.Spec{
|
var SwaggerInfo = &swag.Spec{
|
||||||
Version: "0.8.1",
|
Version: "0.8.2",
|
||||||
Host: "",
|
Host: "",
|
||||||
BasePath: "/",
|
BasePath: "/",
|
||||||
Schemes: []string{},
|
Schemes: []string{},
|
||||||
|
|||||||
+194
-1
@@ -4,7 +4,7 @@
|
|||||||
"description": "自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization: Bearer 或 x-api-key)。",
|
"description": "自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization: Bearer 或 x-api-key)。",
|
||||||
"title": "OCI Portal API",
|
"title": "OCI Portal API",
|
||||||
"contact": {},
|
"contact": {},
|
||||||
"version": "0.8.1"
|
"version": "0.8.2"
|
||||||
},
|
},
|
||||||
"basePath": "/",
|
"basePath": "/",
|
||||||
"paths": {
|
"paths": {
|
||||||
@@ -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": {
|
"/api/v1/oci-configs/{id}/audit-events": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -5788,6 +5825,77 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/oci-configs/{id}/users/{userId}/api-keys": {
|
"/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": {
|
"delete": {
|
||||||
"security": [
|
"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": {
|
"/api/v1/oci-configs/{id}/users/{userId}/mfa-devices": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"security": [
|
"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": {
|
"internal_api.webConsoleSessionResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
"oci-portal_internal_service.ImportFail": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -11905,6 +12080,24 @@
|
|||||||
"type": "string"
|
"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": {
|
"securityDefinitions": {
|
||||||
|
|||||||
+124
-1
@@ -820,6 +820,13 @@ definitions:
|
|||||||
usedBy:
|
usedBy:
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
internal_api.userApiKeysResponse:
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/oci-portal_internal_service.UserApiKeyInfo'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
internal_api.webConsoleSessionResponse:
|
internal_api.webConsoleSessionResponse:
|
||||||
properties:
|
properties:
|
||||||
sessionId:
|
sessionId:
|
||||||
@@ -2619,6 +2626,15 @@ definitions:
|
|||||||
tenancyOcid:
|
tenancyOcid:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
oci-portal_internal_service.CreatedApiKey:
|
||||||
|
properties:
|
||||||
|
configIni:
|
||||||
|
type: string
|
||||||
|
fingerprint:
|
||||||
|
type: string
|
||||||
|
privateKey:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
oci-portal_internal_service.ImportFail:
|
oci-portal_internal_service.ImportFail:
|
||||||
properties:
|
properties:
|
||||||
line:
|
line:
|
||||||
@@ -3006,12 +3022,24 @@ definitions:
|
|||||||
oidcIssuer:
|
oidcIssuer:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
info:
|
||||||
contact: {}
|
contact: {}
|
||||||
description: '自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization:
|
description: '自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization:
|
||||||
Bearer 或 x-api-key)。'
|
Bearer 或 x-api-key)。'
|
||||||
title: OCI Portal API
|
title: OCI Portal API
|
||||||
version: 0.8.1
|
version: 0.8.2
|
||||||
paths:
|
paths:
|
||||||
/ai/v1/audio/speech:
|
/ai/v1/audio/speech:
|
||||||
post:
|
post:
|
||||||
@@ -3964,6 +3992,29 @@ paths:
|
|||||||
summary: 更新租户配置
|
summary: 更新租户配置
|
||||||
tags:
|
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:
|
/api/v1/oci-configs/{id}/audit-events:
|
||||||
get:
|
get:
|
||||||
parameters:
|
parameters:
|
||||||
@@ -6631,6 +6682,78 @@ paths:
|
|||||||
summary: 清除用户全部 API Key
|
summary: 清除用户全部 API Key
|
||||||
tags:
|
tags:
|
||||||
- 租户 IAM
|
- 租户 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:
|
/api/v1/oci-configs/{id}/users/{userId}/mfa-devices:
|
||||||
delete:
|
delete:
|
||||||
parameters:
|
parameters:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ require (
|
|||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/gorilla/websocket v1.5.3
|
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/pquerna/otp v1.5.0
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/swaggo/files v1.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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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/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.1 h1:nXrE3r1qmBULT6Zp2koM2bJGDvsFtMX/be/NV9MS3Bw=
|
||||||
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/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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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=
|
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.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/mfa-devices", h.deleteTenantUserMfa)
|
||||||
g.DELETE("/oci-configs/:id/users/:userId/api-keys", h.deleteTenantUserApiKeys)
|
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.GET("/oci-configs/:id/notification-recipients", h.getNotificationRecipients)
|
||||||
g.PUT("/oci-configs/:id/notification-recipients", h.updateNotificationRecipients)
|
g.PUT("/oci-configs/:id/notification-recipients", h.updateNotificationRecipients)
|
||||||
g.GET("/oci-configs/:id/password-policies", h.listPasswordPolicies)
|
g.GET("/oci-configs/:id/password-policies", h.listPasswordPolicies)
|
||||||
|
|||||||
@@ -157,6 +157,11 @@ type deletedApiKeysResponse struct {
|
|||||||
DeletedApiKeys int `json:"deletedApiKeys"`
|
DeletedApiKeys int `json:"deletedApiKeys"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userApiKeysResponse 是用户 API 签名 key 列表。
|
||||||
|
type userApiKeysResponse struct {
|
||||||
|
Items []service.UserApiKeyInfo `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
// createInstancesResponse 是批量创建实例结果;部分成功仍 201,errors 为逐台失败信息。
|
// createInstancesResponse 是批量创建实例结果;部分成功仍 201,errors 为逐台失败信息。
|
||||||
type createInstancesResponse struct {
|
type createInstancesResponse struct {
|
||||||
Instances []oci.Instance `json:"instances"`
|
Instances []oci.Instance `json:"instances"`
|
||||||
|
|||||||
@@ -371,6 +371,101 @@ func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"deletedApiKeys": deleted})
|
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 ---- 域通知收件人与密码策略 ----
|
// @Summary ---- 域通知收件人与密码策略 ----
|
||||||
|
|||||||
@@ -189,6 +189,10 @@ type Client interface {
|
|||||||
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error)
|
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error)
|
||||||
DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (int, 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)
|
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)
|
GetNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string) (NotificationRecipients, error)
|
||||||
UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []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
|
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。
|
// scimUserID 用经典 OCID 在 Identity Domain 中解析 SCIM 用户 ID。
|
||||||
func scimUserID(ctx context.Context, dc identitydomains.IdentityDomainsClient, classicOCID string) (string, error) {
|
func scimUserID(ctx context.Context, dc identitydomains.IdentityDomainsClient, classicOCID string) (string, error) {
|
||||||
u, found, err := domainUserByOcid(ctx, dc, classicOCID)
|
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