This is an automated email from the ASF dual-hosted git repository.
xuetaoli pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go-pixiu.git
The following commit(s) were added to refs/heads/develop by this push:
new 8fe7b268 Admin opa backend (#877)
8fe7b268 is described below
commit 8fe7b26841f01db0941b9d7976cc7b05cf80ed70
Author: nanjiek <[email protected]>
AuthorDate: Mon Mar 2 11:19:50 2026 +0800
Admin opa backend (#877)
* add the OPA on the admin without readme
* fmt
* add the description of change in readme
* remove the log
* fix the issue
* fix the issue
* delete the log and improve the yml of docker
* remove the redundent -
* add the newLine
* fix(admin): honor OPA request timeout with context and add 30s fallback
---
admin/API.md | 75 +++++
admin/API_CN.md | 75 +++++
admin/README.md | 12 +
admin/README_CN.md | 13 +-
admin/config/config.go | 7 +
.../{web/src/api/menu-config.js => config/opa.go} | 54 ++--
admin/controller/opa/opa.go | 123 ++++++++
admin/doc/docs.go | 123 ++++++++
admin/doc/swagger.json | 126 +++++++-
admin/doc/swagger.yaml | 80 ++++++
admin/initialize/router.go | 5 +
admin/logic/logic.go | 145 ++++++++++
admin/web/src/api/menu-config.js | 10 +-
admin/web/src/router/router.js | 4 +
admin/web/src/views/dashboard/manage/OPA.vue | 319 +++++++++++++++++++++
configs/admin_docker_config.yaml | 6 +
docker-compose.yml | 18 +-
docs/images/admin/14.png | Bin 0 -> 49458 bytes
docs/images/admin/15.png | Bin 0 -> 56099 bytes
19 files changed, 1160 insertions(+), 35 deletions(-)
diff --git a/admin/API.md b/admin/API.md
index 0a365bb3..31f1ffaa 100644
--- a/admin/API.md
+++ b/admin/API.md
@@ -374,3 +374,78 @@ DELETE /config/api/plugin_group/?name=group1 HTTP/1.1
Host: 127.0.0.1:8080
cache-control: no-cache
```
+
+## V. OPA Policy
+
+OPA policy APIs proxy requests to the OPA server. If `server_url` or
`policy_id` is not provided, defaults are used (`http://opa:8181` and
`pixiu-authz`).
+
+### 5.1 Get OPA Policy
+
+**Request**:
+
+```http
+GET /config/api/opa/policy?policy_id=pixiu-authz HTTP/1.1
+Host: 127.0.0.1:8080
+cache-control: no-cache
+```
+
+**Query Params**:
+
+* `policy_id`: OPA policy id (optional)
+* `server_url`: OPA server URL (optional)
+* `bearer_token`: OPA bearer token (optional)
+
+**Response**:
+
+```json
+{
+ "code": "10001",
+ "data": "package pixiu.authz\n\ndefault allow := false\n"
+}
+```
+
+If the policy does not exist, `data` will be an empty string.
+
+### 5.2 Create or Update OPA Policy
+
+**Request**:
+
+```http
+PUT /config/api/opa/policy HTTP/1.1
+Host: 127.0.0.1:8080
+Content-Type: multipart/form-data; boundary=-WebKitFormBoundary7MA4YWxkTrZu0gW
+cache-control: no-cache
+```
+
+**Form Data**:
+
+```text
+Content-Disposition: form-data; name="policy_id"
+pixiu-authz
+
+Content-Disposition: form-data; name="content"
+package pixiu.authz
+
+default allow := false
+```
+
+Optional form fields:
+
+* `server_url`
+* `bearer_token`
+
+### 5.3 Delete OPA Policy
+
+**Request**:
+
+```http
+DELETE /config/api/opa/policy?policy_id=pixiu-authz HTTP/1.1
+Host: 127.0.0.1:8080
+cache-control: no-cache
+```
+
+**Query Params**:
+
+* `policy_id`: OPA policy id (optional)
+* `server_url`: OPA server URL (optional)
+* `bearer_token`: OPA bearer token (optional)
diff --git a/admin/API_CN.md b/admin/API_CN.md
index a4c02f11..c07c6f87 100644
--- a/admin/API_CN.md
+++ b/admin/API_CN.md
@@ -376,3 +376,78 @@ DELETE /config/api/plugin_group/?name=group1 HTTP/1.1
Host: 127.0.0.1:8080
cache-control: no-cache
```
+
+## 五、OPA 策略
+
+OPA 策略接口会代理请求到 OPA 服务端。未提供 `server_url` 或 `policy_id`
时,会使用默认值(`http://opa:8181` 和 `pixiu-authz`)。
+
+### 5.1 获取 OPA 策略
+
+**请求**:
+
+```http
+GET /config/api/opa/policy?policy_id=pixiu-authz HTTP/1.1
+Host: 127.0.0.1:8080
+cache-control: no-cache
+```
+
+**Query 参数**:
+
+* `policy_id`: OPA policy id(可选)
+* `server_url`: OPA 服务地址(可选)
+* `bearer_token`: OPA Bearer Token(可选)
+
+**返回**:
+
+```json
+{
+ "code": "10001",
+ "data": "package pixiu.authz\n\ndefault allow := false\n"
+}
+```
+
+若策略不存在,`data` 返回空字符串。
+
+### 5.2 新增或更新 OPA 策略
+
+**请求**:
+
+```http
+PUT /config/api/opa/policy HTTP/1.1
+Host: 127.0.0.1:8080
+Content-Type: multipart/form-data; boundary=-WebKitFormBoundary7MA4YWxkTrZu0gW
+cache-control: no-cache
+```
+
+**表单数据**:
+
+```text
+Content-Disposition: form-data; name="policy_id"
+pixiu-authz
+
+Content-Disposition: form-data; name="content"
+package pixiu.authz
+
+default allow := false
+```
+
+可选表单字段:
+
+* `server_url`
+* `bearer_token`
+
+### 5.3 删除 OPA 策略
+
+**请求**:
+
+```http
+DELETE /config/api/opa/policy?policy_id=pixiu-authz HTTP/1.1
+Host: 127.0.0.1:8080
+cache-control: no-cache
+```
+
+**Query 参数**:
+
+* `policy_id`: OPA policy id(可选)
+* `server_url`: OPA 服务地址(可选)
+* `bearer_token`: OPA Bearer Token(可选)
diff --git a/admin/README.md b/admin/README.md
index 232399d3..9ae7efa2 100644
--- a/admin/README.md
+++ b/admin/README.md
@@ -277,6 +277,18 @@ After saving, the rate-limiting configuration will take
effect.

+### Manage OPA Policy Configuration
+
+#### Configure OPA Policy
+
+Click the "OPA Policy Configuration" menu to manage the OPA policy. You can
change `policy_id`, synchronize the latest policy from the OPA server, and edit
Rego policy content in the editor.
+
+
+
+After editing, click "Save" to upload the policy to OPA. You can also click
"Delete" to remove the policy.
+
+
+
## III. Pixiu Remote Configuration
### Start and Configure
diff --git a/admin/README_CN.md b/admin/README_CN.md
index ed2c1843..8ca4ee65 100644
--- a/admin/README_CN.md
+++ b/admin/README_CN.md
@@ -278,6 +278,18 @@ rules:

+### 管理 OPA 策略配置
+
+#### 配置 OPA 策略
+
+点击 "OPA 策略配置" 菜单,可以修改 `policy_id`,同步 OPA 策略,并在编辑器中编写 Rego 策略内容。
+
+
+
+编辑完成后,点击 "保存" 将策略提交到 OPA;也可以点击 "删除" 删除该策略。
+
+
+
## 三、Pixiu 远程配置
### 启动和配置
@@ -304,4 +316,3 @@ curl -X POST
"http://127.0.0.1:8888/api/v1/test-dubbo/user?name=tc"
## 许可证
本项目采用 Apache License 2.0 开源许可。
-
diff --git a/admin/config/config.go b/admin/config/config.go
index 6ad9dec2..1083ebd8 100644
--- a/admin/config/config.go
+++ b/admin/config/config.go
@@ -54,6 +54,7 @@ type AdminBootstrap struct {
Server ServerConfig `yaml:"server" json:"server"
mapstructure:"server"`
EtcdConfig EtcdConfig `yaml:"etcd" json:"etcd" mapstructure:"etcd"`
MysqlConfig MysqlConfig `yaml:"mysql" json:"mysql"
mapstructure:"mysql"`
+ OPA OPAConfig `yaml:"opa" json:"opa" mapstructure:"opa"`
}
// GetAddress get etcd server address
@@ -86,6 +87,12 @@ type MysqlConfig struct {
Dbname string `yaml:"dbname" json:"dbname" mapstructure:"dbname"`
}
+type OPAConfig struct {
+ ServerURL string `yaml:"server_url" json:"server_url"
mapstructure:"server_url"`
+ PolicyID string `yaml:"policy_id" json:"policy_id"
mapstructure:"policy_id"`
+ RequestTimeout time.Duration `yaml:"request_timeout"
json:"request_timeout" mapstructure:"request_timeout"`
+}
+
// BaseInfo base info
type BaseInfo struct {
Name string `json:"name" yaml:"name"`
diff --git a/admin/web/src/api/menu-config.js b/admin/config/opa.go
similarity index 59%
copy from admin/web/src/api/menu-config.js
copy to admin/config/opa.go
index 3183c7f5..cbc1dbf3 100644
--- a/admin/web/src/api/menu-config.js
+++ b/admin/config/opa.go
@@ -14,33 +14,27 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-export const menuList = [{
- name: '网关配置',
- id: 'Gateway',
- children: [{
- name: '概览',
- id: 'Overview',
- componentName: '/Overview'
- },
- {
- name: '插件配置',
- id: 'Plug',
- componentName: 'Plug'
- },{
- name: '集群管理',
- id: 'Cluster',
- componentName: 'Cluster'
- },{
- name: 'Listener管理',
- id: 'Listener',
- componentName: 'Listener'
- }]
-}, {
- name: '限流配置',
- id: 'Flow',
- children: [{
- name: '限流配置',
- id: 'RateLimiter',
- componentName: '/RateLimiter'
- }]
-}]
+
+package config
+
+import (
+ "time"
+)
+
+const (
+ DefaultOPAServerURL = "http://opa:8181"
+ DefaultOPAPolicyID = "pixiu-authz"
+ DefaultOPAPolicyTimeout = 8 * time.Second
+)
+
+type OPAQuery struct {
+ ServerURL string `form:"server_url"`
+ PolicyID string `form:"policy_id"`
+ BearerToken string `form:"bearer_token"`
+}
+
+type OPAPolicyGetResponse struct {
+ Result struct {
+ Raw string `json:"raw"`
+ } `json:"result"`
+}
diff --git a/admin/controller/opa/opa.go b/admin/controller/opa/opa.go
new file mode 100644
index 00000000..1b1673fe
--- /dev/null
+++ b/admin/controller/opa/opa.go
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opa
+
+import (
+ "net/http"
+ "strings"
+)
+
+import (
+ "github.com/gin-gonic/gin"
+
+ perrors "github.com/pkg/errors"
+)
+
+import (
+ adminconfig "github.com/apache/dubbo-go-pixiu/admin/config"
+ "github.com/apache/dubbo-go-pixiu/admin/logic"
+)
+
+// @Tags Config
+// @Summary upload OPA policy (server mode)
+// @Router /config/api/opa/policy [put]
+func PutOPAPolicy(c *gin.Context) {
+ // For PUT, we typically expect Form Data
+ serverURL := resolveOPAServerURL(c.PostForm("server_url"))
+ policyID := resolveOPAPolicyID(c.PostForm("policy_id"))
+ bearerToken := c.PostForm("bearer_token")
+ content := c.PostForm("content")
+
+ if content == "" {
+ c.JSON(http.StatusOK, adminconfig.WithError(perrors.New("rego
content is required")))
+ return
+ }
+
+ if err := logic.BizPutOPAPolicy(serverURL, policyID, bearerToken,
content); err != nil {
+ c.JSON(http.StatusOK, adminconfig.WithError(err))
+ return
+ }
+ c.JSON(http.StatusOK, adminconfig.WithRet("Update Success"))
+}
+
+// @Tags Config
+// @Summary get OPA policy (server mode)
+// @Router /config/api/opa/policy [get]
+func GetOPAPolicy(c *gin.Context) {
+ var query adminconfig.OPAQuery
+ if err := c.ShouldBindQuery(&query); err != nil {
+ c.JSON(http.StatusOK, adminconfig.WithError(err))
+ return
+ }
+
+ serverURL := resolveOPAServerURL(query.ServerURL)
+ policyID := resolveOPAPolicyID(query.PolicyID)
+
+ result, err := logic.BizGetOPAPolicy(serverURL, policyID,
query.BearerToken)
+ if err != nil {
+ c.JSON(http.StatusOK, adminconfig.WithError(err))
+ return
+ }
+ c.JSON(http.StatusOK, adminconfig.WithRet(result))
+}
+
+// @Tags Config
+// @Summary delete OPA policy (server mode)
+// @Router /config/api/opa/policy [delete]
+func DeleteOPAPolicy(c *gin.Context) {
+ var query adminconfig.OPAQuery
+ if err := c.ShouldBindQuery(&query); err != nil {
+ c.JSON(http.StatusOK, adminconfig.WithError(err))
+ return
+ }
+
+ serverURL := resolveOPAServerURL(query.ServerURL)
+ policyID := resolveOPAPolicyID(query.PolicyID)
+
+ if err := logic.BizDeleteOPAPolicy(serverURL, policyID,
query.BearerToken); err != nil {
+ c.JSON(http.StatusOK, adminconfig.WithError(err))
+ return
+ }
+ c.JSON(http.StatusOK, adminconfig.WithRet("Delete Success"))
+}
+
+func resolveOPAServerURL(serverURL string) string {
+ serverURL = strings.TrimSpace(serverURL)
+ if serverURL != "" {
+ return serverURL
+ }
+ if adminconfig.Bootstrap != nil {
+ if trimmed :=
strings.TrimSpace(adminconfig.Bootstrap.OPA.ServerURL); trimmed != "" {
+ return trimmed
+ }
+ }
+ return adminconfig.DefaultOPAServerURL
+}
+
+func resolveOPAPolicyID(policyID string) string {
+ policyID = strings.TrimSpace(policyID)
+ if policyID != "" {
+ return policyID
+ }
+ if adminconfig.Bootstrap != nil {
+ if trimmed :=
strings.TrimSpace(adminconfig.Bootstrap.OPA.PolicyID); trimmed != "" {
+ return trimmed
+ }
+ }
+ return adminconfig.DefaultOPAPolicyID
+}
diff --git a/admin/doc/docs.go b/admin/doc/docs.go
index 87fb91ae..d37b2fcc 100644
--- a/admin/doc/docs.go
+++ b/admin/doc/docs.go
@@ -817,6 +817,129 @@ const docTemplate = `{
}
}
},
+ "/config/api/opa/policy": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Config"
+ ],
+ "summary": "get OPA policy (server mode)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "OPA server url",
+ "name": "server_url",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Policy ID",
+ "name": "policy_id",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Bearer token",
+ "name": "bearer_token",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "put": {
+ "consumes": [
+ "application/x-www-form-urlencoded"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Config"
+ ],
+ "summary": "upload OPA policy (server mode)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "OPA server url",
+ "name": "server_url",
+ "in": "formData"
+ },
+ {
+ "type": "string",
+ "description": "Policy ID",
+ "name": "policy_id",
+ "in": "formData"
+ },
+ {
+ "type": "string",
+ "description": "Bearer token",
+ "name": "bearer_token",
+ "in": "formData"
+ },
+ {
+ "type": "string",
+ "description": "Rego content",
+ "name": "content",
+ "in": "formData",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "delete": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Config"
+ ],
+ "summary": "delete OPA policy (server mode)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "OPA server url",
+ "name": "server_url",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Policy ID",
+ "name": "policy_id",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Bearer token",
+ "name": "bearer_token",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
"/config/api/resource/publish": {
"put": {
"description": "publish resources from unpublished spaces to
published spaces",
diff --git a/admin/doc/swagger.json b/admin/doc/swagger.json
index 7b1b085e..17c9acfc 100644
--- a/admin/doc/swagger.json
+++ b/admin/doc/swagger.json
@@ -789,7 +789,129 @@
}
}
},
- "/config/api/resource/publish": {
+ "/config/api/opa/policy": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Config"
+ ],
+ "summary": "get OPA policy (server mode)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "OPA server url",
+ "name": "server_url",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Policy ID",
+ "name": "policy_id",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Bearer token",
+ "name": "bearer_token",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "put": {
+ "consumes": [
+ "application/x-www-form-urlencoded"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Config"
+ ],
+ "summary": "upload OPA policy (server mode)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "OPA server url",
+ "name": "server_url",
+ "in": "formData"
+ },
+ {
+ "type": "string",
+ "description": "Policy ID",
+ "name": "policy_id",
+ "in": "formData"
+ },
+ {
+ "type": "string",
+ "description": "Bearer token",
+ "name": "bearer_token",
+ "in": "formData"
+ },
+ {
+ "type": "string",
+ "description": "Rego content",
+ "name": "content",
+ "in": "formData",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "delete": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Config"
+ ],
+ "summary": "delete OPA policy (server mode)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "OPA server url",
+ "name": "server_url",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Policy ID",
+ "name": "policy_id",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Bearer token",
+ "name": "bearer_token",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }, "/config/api/resource/publish": {
"put": {
"description": "publish resources from unpublished spaces to
published spaces",
"produces": [
@@ -964,4 +1086,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/admin/doc/swagger.yaml b/admin/doc/swagger.yaml
index 3848f685..3a2566f9 100644
--- a/admin/doc/swagger.yaml
+++ b/admin/doc/swagger.yaml
@@ -535,6 +535,86 @@ paths:
summary: batch Release Method Config
tags:
- Config
+ /config/api/opa/policy:
+ get:
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: string
+ summary: get OPA policy (server mode)
+ tags:
+ - Config
+ parameters:
+ - description: OPA server url
+ in: query
+ name: server_url
+ type: string
+ - description: Policy ID
+ in: query
+ name: policy_id
+ type: string
+ - description: Bearer token
+ in: query
+ name: bearer_token
+ type: string
+ put:
+ consumes:
+ - application/x-www-form-urlencoded
+ parameters:
+ - description: OPA server url
+ in: formData
+ name: server_url
+ type: string
+ - description: Policy ID
+ in: formData
+ name: policy_id
+ type: string
+ - description: Bearer token
+ in: formData
+ name: bearer_token
+ type: string
+ - description: Rego content
+ in: formData
+ name: content
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: string
+ summary: upload OPA policy (server mode)
+ tags:
+ - Config
+ delete:
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: string
+ summary: delete OPA policy (server mode)
+ tags:
+ - Config
+ parameters:
+ - description: OPA server url
+ in: query
+ name: server_url
+ type: string
+ - description: Policy ID
+ in: query
+ name: policy_id
+ type: string
+ - description: Bearer token
+ in: query
+ name: bearer_token
+ type: string
/config/api/resource/publish:
put:
description: publish resources from unpublished spaces to published
spaces
diff --git a/admin/initialize/router.go b/admin/initialize/router.go
index 5374ce02..b20cf927 100644
--- a/admin/initialize/router.go
+++ b/admin/initialize/router.go
@@ -29,6 +29,7 @@ import (
"github.com/apache/dubbo-go-pixiu/admin/controller/account"
"github.com/apache/dubbo-go-pixiu/admin/controller/auth"
"github.com/apache/dubbo-go-pixiu/admin/controller/configInfo"
+ "github.com/apache/dubbo-go-pixiu/admin/controller/opa"
_ "github.com/apache/dubbo-go-pixiu/admin/doc"
)
@@ -82,6 +83,10 @@ func Routers() *gin.Engine {
taR.PUT("/config/api/resource/method",
configInfo.ModifyMethodInfo)
taR.DELETE("/config/api/resource/method",
configInfo.DeleteMethodInfo)
+ taR.GET("/config/api/opa/policy", opa.GetOPAPolicy)
+ taR.PUT("/config/api/opa/policy", opa.PutOPAPolicy)
+ taR.DELETE("/config/api/opa/policy", opa.DeleteOPAPolicy)
+
// Which request method to choose, Temporarily choose put method
taR.PUT("/config/api/resource/publish",
configInfo.BatchReleaseResource)
taR.PUT("/config/api/resource/method/publish",
configInfo.BatchReleaseMethod)
diff --git a/admin/logic/logic.go b/admin/logic/logic.go
index dab5aea3..dbdfd414 100644
--- a/admin/logic/logic.go
+++ b/admin/logic/logic.go
@@ -18,10 +18,17 @@
package logic
import (
+ "bytes"
+ "context"
+ "encoding/json"
"errors"
+ "fmt"
+ "io"
+ "net/http"
"regexp"
"strconv"
"strings"
+ "time"
)
import (
@@ -51,6 +58,7 @@ const (
Plugin = "plugin"
Filter = "filter"
Ratelimit = "ratelimit"
+ OPA = "opa"
Clusters = "clusters"
Listeners = "listeners"
Unpublished = "unpublished"
@@ -58,6 +66,22 @@ const (
ErrID = -1
)
+// Use a shared client to enable HTTP keep-alive and prevent connection
exhaustion.
+// Timeout is enforced per request so it can honor late-loaded config.
+// Set a large fallback timeout as a last-resort guard.
+const opaHTTPClientFallbackTimeout = 30 * time.Second
+
+var opaHTTPClient = &http.Client{
+ Timeout: opaHTTPClientFallbackTimeout,
+}
+
+func getOPATimeout() time.Duration {
+ if adminconfig.Bootstrap != nil &&
adminconfig.Bootstrap.OPA.RequestTimeout > 0 {
+ return adminconfig.Bootstrap.OPA.RequestTimeout
+ }
+ return adminconfig.DefaultOPAPolicyTimeout
+}
+
// BizGetBaseInfo get base info
func BizGetBaseInfo() (*adminconfig.BaseInfo, error) {
content, err := adminconfig.Client.Get(getRootPath(Base))
@@ -510,6 +534,127 @@ func BRCreate(key, value, configType string) error {
return errors.New("")
}
+// BizGetOPAPolicy fetches the policy raw text. Returns empty string if not
found.
+func BizGetOPAPolicy(serverURL, policyID, bearerToken string) (string, error) {
+ url, err := buildOPAPolicyURL(serverURL, policyID)
+ if err != nil {
+ return "", err
+ }
+
+ status, body, err := doOPARequestWithStatus(http.MethodGet, url,
bearerToken, "", nil)
+ if err != nil {
+ return "", err
+ }
+
+ // Handle the "initial state" where the policy doesn't exist yet
+ if status == http.StatusNotFound {
+ return "", nil
+ }
+
+ var decoded adminconfig.OPAPolicyGetResponse
+ if err := json.Unmarshal(body, &decoded); err != nil {
+ return "", perrors.WithMessage(err, "failed to decode OPA
response")
+ }
+
+ return decoded.Result.Raw, nil
+}
+
+// BizPutOPAPolicy updates or creates a policy.
+func BizPutOPAPolicy(serverURL, policyID, bearerToken, policy string) error {
+ url, err := buildOPAPolicyURL(serverURL, policyID)
+ if err != nil {
+ return err
+ }
+
+ if strings.TrimSpace(policy) == "" {
+ return perrors.New("policy content is required")
+ }
+
+ normalized := strings.ReplaceAll(policy, "\r\n", "\n")
+ _, _, err = doOPARequestWithStatus(http.MethodPut, url, bearerToken,
"text/plain", []byte(normalized))
+ return err
+}
+
+// BizDeleteOPAPolicy removes a policy. Returns nil if policy is already gone.
+func BizDeleteOPAPolicy(serverURL, policyID, bearerToken string) error {
+ url, err := buildOPAPolicyURL(serverURL, policyID)
+ if err != nil {
+ return err
+ }
+
+ _, _, err = doOPARequestWithStatus(http.MethodDelete, url, bearerToken,
"", nil)
+ return err
+}
+
+func buildOPAPolicyURL(serverURL, policyID string) (string, error) {
+ serverURL = strings.TrimSpace(serverURL)
+ policyID = strings.TrimSpace(policyID)
+
+ if serverURL == "" || policyID == "" {
+ return "", perrors.New("server_url and policy_id are required")
+ }
+
+ base := strings.TrimRight(serverURL, "/")
+ return fmt.Sprintf("%s/v1/policies/%s", base, policyID), nil
+}
+
+// doOPARequestWithStatus is the core proxy function
+func doOPARequestWithStatus(method, url, bearerToken, contentType string, body
[]byte) (int, []byte, error) {
+ var reader io.Reader
+ if body != nil {
+ reader = bytes.NewReader(body)
+ }
+
+ ctx := context.Background()
+ if adminconfig.Client != nil {
+ ctx = adminconfig.Client.GetCtx()
+ }
+ if timeout := getOPATimeout(); timeout > 0 {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, timeout)
+ defer cancel()
+ }
+ req, err := http.NewRequestWithContext(ctx, method, url, reader)
+ if err != nil {
+ return 0, nil, perrors.Wrap(err, "failed to create OPA request")
+ }
+
+ if contentType != "" {
+ req.Header.Set("Content-Type", contentType)
+ }
+ if token := strings.TrimSpace(bearerToken); token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+
+ resp, err := opaHTTPClient.Do(req)
+ if err != nil {
+ return 0, nil, perrors.Wrap(err, "OPA connection failed")
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ logger.Warnf("failed to read OPA response body: %v", err)
+ }
+
+ switch resp.StatusCode {
+ case http.StatusOK:
+ return resp.StatusCode, respBody, nil
+
+ case http.StatusNotFound:
+ if method == http.MethodGet || method == http.MethodDelete {
+ return resp.StatusCode, nil, nil
+ }
+ return resp.StatusCode, nil, perrors.Errorf("OPA endpoint not
found: %s", url)
+
+ default:
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >=
http.StatusMultipleChoices {
+ return resp.StatusCode, nil, perrors.Errorf("OPA status
%d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
+ }
+ return resp.StatusCode, respBody, nil
+ }
+}
+
func getResourceKey(path string, unpublished bool) string {
if unpublished {
return getUnpublishedRootPath(Resources) + "/" + path
diff --git a/admin/web/src/api/menu-config.js b/admin/web/src/api/menu-config.js
index 3183c7f5..f12d17ae 100644
--- a/admin/web/src/api/menu-config.js
+++ b/admin/web/src/api/menu-config.js
@@ -43,4 +43,12 @@ export const menuList = [{
id: 'RateLimiter',
componentName: '/RateLimiter'
}]
-}]
+}, {
+ name: 'OPA配置',
+ id: 'OPAConfig',
+ children: [{
+ name: 'OPA配置',
+ id: 'OPA',
+ componentName: '/OPA'
+ }]
+}]
diff --git a/admin/web/src/router/router.js b/admin/web/src/router/router.js
index 8c6e0012..4ea557a9 100644
--- a/admin/web/src/router/router.js
+++ b/admin/web/src/router/router.js
@@ -63,6 +63,10 @@ export default new Router({
path: 'RateLimiter',
component: () => import('@/views/dashboard/manage/RateLimiter.vue')
},
+ {
+ path: 'OPA',
+ component: () => import('@/views/dashboard/manage/OPA.vue')
+ },
{
path: 'personInfo',
component: () => import('@/views/dashboard/personInfo/index.vue')
diff --git a/admin/web/src/views/dashboard/manage/OPA.vue
b/admin/web/src/views/dashboard/manage/OPA.vue
new file mode 100644
index 00000000..3b4f0249
--- /dev/null
+++ b/admin/web/src/views/dashboard/manage/OPA.vue
@@ -0,0 +1,319 @@
+<!--
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ -->
+<template>
+ <CustomLayout>
+ <div class="custom-body">
+ <div>
+ <CommonTitle title="OPA 策略配置"></CommonTitle>
+ </div>
+ <div class="custom-tools">
+ <div class="table-head">
+ <div class="custom-tools__info">OPA 策略</div>
+ <div>
+ <el-button size="mini" @click="handleSync">同步</el-button>
+ <el-button type="primary" size="mini"
@click="handleSave">保存</el-button>
+ <el-popconfirm
+ title="确定要删除该策略吗?"
+ @confirm="handleDelete">
+ <el-button slot="reference" type="danger"
size="mini">删除</el-button>
+ </el-popconfirm>
+ </div>
+ </div>
+ <div class="custom-tools__content">
+ <el-form :model="form"
+ :inline="true"
+ @submit.native.prevent=""
+ class="table-form bg-gray"
+ label-width="130px">
+ <el-row>
+ <el-form-item label="policy_id">
+ <el-input v-model="form.policy_id"
+ clearable
+ placeholder="pixiu-authz"></el-input>
+ </el-form-item>
+
+ </el-row>
+ <el-row>
+ <div style="clear: both; height: 300px;width: 100%;"
id="policyEditor" ref="policyEditor"/>
+ </el-row>
+ </el-form>
+ </div>
+ </div>
+ </div>
+ </CustomLayout>
+</template>
+
+<script>
+import CommonTitle from '@/components/common/CommonTitle'
+import CustomLayout from '@/components/common/CustomLayout.vue'
+import * as monaco from 'monaco-editor/esm/vs/editor/editor.main.js'
+import
'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution'
+import { getLocalStorage, setLocalStorage } from '@/utils/auth'
+
+const POLICY_CACHE_KEY = 'opaPolicyLast'
+const DEFAULT_POLICY_ID = 'pixiu-authz'
+
+const DEFAULT_POLICY = `package pixiu.authz
+
+default allow := false
+
+allow if {
+ # write your logic here
+}
+`
+const CONTROL_CHAR_REGEX =
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u2028\u2029\uFEFF]/g
+let regoRegistered = false
+
+function normalizePolicyText(value) {
+ if (!value) {
+ return ''
+ }
+ return value
+ .replace(/\r\n/g, '\n')
+ .replace(/\r/g, '\n')
+ .replace(CONTROL_CHAR_REGEX, '')
+}
+
+function registerRegoLanguage(monaco) {
+ if (regoRegistered) {
+ return
+ }
+ regoRegistered = true
+ monaco.languages.register({ id: 'rego' })
+ monaco.languages.setMonarchTokensProvider('rego', {
+ keywords: [
+ 'package', 'default', 'import', 'as', 'with', 'not', 'some',
+ 'else', 'if', 'in', 'true', 'false', 'null'
+ ],
+ operators: [
+ '=', ':=', '==', '!=', '<', '>', '<=', '>=', '+', '-', '*', '/',
+ '%', 'and', 'or'
+ ],
+ tokenizer: {
+ root: [
+ [/[a-zA-Z_][\w\-]*/, {
+ cases: {
+ '@keywords': 'keyword',
+ '@default': 'identifier'
+ }
+ }],
+ [/[{}()[\]]/, '@brackets'],
+ [/[=><!]=?/, 'operator'],
+ [/\"([^\"\\]|\\.)*$/, 'string.invalid'],
+ [/\"/, { token: 'string.quote', bracket: '@open', next: '@string' }],
+ [/#[^\r\n]*/, 'comment'],
+ [/\d+(\.\d+)?/, 'number'],
+ [/[;,.]/, 'delimiter']
+ ],
+ string: [
+ [/[^\\"]+/, 'string'],
+ [/\\./, 'string.escape'],
+ [/\"/, { token: 'string.quote', bracket: '@close', next: '@pop' }]
+ ]
+ }
+ })
+}
+
+export default {
+ name: 'OPAPolicyConfig',
+ components: {
+ CommonTitle,
+ CustomLayout
+ },
+ data () {
+ return {
+ form: {
+ policy_id: DEFAULT_POLICY_ID
+ },
+ monacoEditor: null
+ }
+ },
+ mounted () {
+ this.$nextTick(() => {
+ this.initPolicyEditor()
+ this.handleSync(false)
+ window.addEventListener('resize', this.handleEditorResize)
+ })
+ },
+ methods: {
+ handleEditorResize() {
+ if (this.monacoEditor) {
+ this.monacoEditor.layout()
+ }
+ },
+ initPolicyEditor() {
+ registerRegoLanguage(monaco)
+ let cached = getLocalStorage(POLICY_CACHE_KEY)
+ let value = cached && cached !== '' ? cached : DEFAULT_POLICY
+ this.monacoEditor =
monaco.editor.create(document.getElementById('policyEditor'), {
+ value,
+ language: 'rego',
+ codeLens: true,
+ selectOnLineNumbers: true,
+ roundedSelection: false,
+ readOnly: false,
+ lineNumbers: 'on',
+ theme: 'vs-dark',
+ wordWrapColumn: 120,
+ folding: false,
+ showFoldingControls: 'always',
+ wordWrap: 'wordWrapColumn',
+ cursorStyle: 'line',
+ automaticLayout: true
+ })
+ const model = this.monacoEditor.getModel()
+ if (model) {
+ model.setEOL(monaco.editor.EndOfLineSequence.LF)
+ }
+ monaco.editor.remeasureFonts()
+ this.monacoEditor.layout()
+ },
+ handleSync(showMessage = true) {
+ this.$get('/config/api/opa/policy', {
+ policy_id: this.form.policy_id || DEFAULT_POLICY_ID
+ })
+ .then((res) => {
+ if (res) {
+ let content = ''
+ if (typeof res === 'object') {
+ if (res.code == 10001) {
+ content = res.data || ''
+ } else if (res.result && typeof res.result.raw === 'string') {
+ content = res.result.raw
+ } else if (typeof res.data === 'string') {
+ content = res.data
+ }
+ } else if (typeof res === 'string') {
+ content = res
+ }
+ if (content.trim() === '') {
+ this.setEditorValue(DEFAULT_POLICY)
+ if (showMessage) {
+ this.$message({
+ type: 'warning',
+ message: '当前无策略,请编写并部署',
+ })
+ }
+ } else {
+ this.setEditorValue(content)
+ }
+ }
+ })
+ .catch((err) => {
+ console.error(err)
+ this.$message({
+ type: 'error',
+ message: '同步失败,请稍后重试',
+ })
+ })
+ },
+ handleDelete() {
+ this.$delete('/config/api/opa/policy', {
+ policy_id: this.form.policy_id || DEFAULT_POLICY_ID
+ })
+ .then((res) => {
+ if (res.code == 10001) {
+ this.setEditorValue(DEFAULT_POLICY)
+ this.$message({
+ type: 'success',
+ message: '删除成功',
+ })
+ }
+ })
+ .catch((err) => {
+ console.log(err)
+ })
+ },
+ setEditorValue(value) {
+ if (this.monacoEditor) {
+ this.monacoEditor.setValue(value)
+ const model = this.monacoEditor.getModel()
+ if (model) {
+ model.setEOL(monaco.editor.EndOfLineSequence.LF)
+ }
+ this.$nextTick(() => {
+ this.monacoEditor.layout()
+ })
+ }
+ },
+ handleSave() {
+ let formData = new FormData()
+ let policy = this.monacoEditor ? this.monacoEditor.getValue() : ''
+ if (!policy || policy.trim() === '') {
+ this.$message({
+ type: 'warning',
+ message: '策略内容不能为空',
+ })
+ return
+ }
+ policy = normalizePolicyText(policy)
+ formData.append('content', policy)
+ formData.append('policy_id', this.form.policy_id || DEFAULT_POLICY_ID)
+ this.$put('/config/api/opa/policy', formData)
+ .then((res) => {
+ if (res.code == 10001) {
+ setLocalStorage(POLICY_CACHE_KEY, policy)
+ this.$message({
+ type: 'success',
+ message: '保存成功',
+ })
+ }
+ })
+ .catch((err) => {
+ console.log(err)
+ })
+ }
+ },
+ destroyed() {
+ if (this.monacoEditor) {
+ this.monacoEditor.dispose()
+ }
+ window.removeEventListener('resize', this.handleEditorResize)
+ }
+}
+</script>
+
+<style scoped lang="less">
+.custom-panel{
+ margin-top: 20px;
+}
+.custom-tools__info{
+ color: rgba(16, 16, 16, 100);
+ font-size: 18px;
+ text-align: left;
+ margin-top: 10px;
+}
+.custom-tools__content{
+ background-color: #fff;
+ margin-top: 10px;
+ padding: 10px 20px;
+}
+.table-head{
+ display: flex;
+ margin-top: 10px;
+ justify-content: space-between;
+}
+
+</style>
+<style lang="less">
+#policyEditor {
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+ font-family: Consolas, "Courier New", monospace;
+}
+</style>
diff --git a/configs/admin_docker_config.yaml b/configs/admin_docker_config.yaml
index f42f8bc6..e73ce876 100644
--- a/configs/admin_docker_config.yaml
+++ b/configs/admin_docker_config.yaml
@@ -33,3 +33,9 @@ system:
env: 'public'
addr: 8081
db-type: 'mysql'
+
+# opa configuration
+opa:
+ server_url: http://opa:8181
+ policy_id: pixiu-authz
+ request_timeout: 8s
diff --git a/docker-compose.yml b/docker-compose.yml
index 1d2283f5..293cbfff 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -35,6 +35,20 @@ services:
networks:
- app_network
+ # OPA service
+ opa:
+ image: openpolicyagent/opa:1.13.1
+ container_name: pixiu_admin_opa
+ ports:
+ - "8181:8181"
+ networks:
+ - app_network
+ command:
+ - "run"
+ - "--server"
+ - "--addr=:8181"
+ - "--log-level=info"
+
mysql_pixiu:
image: mysql:8
container_name: pixiu_admin_mysql
@@ -65,6 +79,7 @@ services:
- app_network
depends_on:
- etcd # Ensure etcd is ready before starting the backend
+ - opa # Ensure opa is ready before starting the backend
- backend # backend is the xds server for pixiu
entrypoint: ["/bin/sh", "-c", "echo 'Waiting for backend to be ready on
port 8081...' && \
until nc -z pixiu_admin_go_backend 8081; do \
@@ -117,4 +132,5 @@ services:
networks:
app_network:
- driver: bridge
\ No newline at end of file
+ driver: bridge
+
diff --git a/docs/images/admin/14.png b/docs/images/admin/14.png
new file mode 100644
index 00000000..d131e8cd
Binary files /dev/null and b/docs/images/admin/14.png differ
diff --git a/docs/images/admin/15.png b/docs/images/admin/15.png
new file mode 100644
index 00000000..b5f20020
Binary files /dev/null and b/docs/images/admin/15.png differ